Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Significantly speed up BeEquivalentTo for large unordered collections - #3188

Merged
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to
Apr 12, 2026
Merged

Significantly speed up BeEquivalentTo for large unordered collections#3188
dennisdoomen merged 6 commits into
fluentassertions:mainfrom
dennisdoomen:perf/speed-up-be-equivalent-to

Conversation

@dennisdoomen

@dennisdoomendennisdoomen commented Apr 6, 2026

Copy link
Copy Markdown
Member

Summary

This PR significantly improves the performance of BeEquivalentTo when comparing large unordered collections (using WithoutStrictOrdering()). Focus benchmark: 500 complex objects vs 500 completely different objects.

The work is split across five focused commits.

Changes

1. Replace O(n!) permutation search with O(n^2 log n) greedy assignment (Phase 3)

For n > 8 subjects, the original brute-force permutation search was replaced with a greedy strategy: compute all n^2 failure-count pairs, sort ascending, pick closest unassigned pair. n <= 8 keeps the exact search.

Also extracts ReferentialComparer into its own file and updates the benchmark target.

2. Pre-cache collection index strings 0-1023

AsCollectionItem<T>(int index) called index.ToString() on every invocation. A static string[] cache for indices 0-1023 eliminates millions of small string allocations.

3. Skip failure message formatting in dry-run comparisons

Phases 1 and 2 only need to count failures, not format them. Three-part change:

  • AssertionScope: new UseDryRun flag, RegisterFailure() counter, GetFailureCount() method
  • AssertionChain: when UseDryRun is set, call RegisterFailure() instead of formatting
  • LooselyOrderedEquivalencyStrategy: new TryToMatchCount() for counting; split caches (countCache for ints, fullFailuresCache for strings); FindClosestMismatches takes separate count/full-failures delegates

Eliminates ~3.25M FailureMessageFormatter calls and ~6.5M Regex.Replace calls from Phases 1+2.

4. Clone CyclicReferenceDetector per dry-run comparison

Dry-runs shared the parent context's CyclicReferenceDetector. When the same object appears in multiple positions, the detector incorrectly flagged subsequent dry-runs as cyclic -- causing wrong counts and stack overflows. Fix: clone the detector for each dry-run call. Exposes the setter as internal.

5. Include nested scope failures in dry-run failure count

GetFailureCount() was missing failures from nested assertion scopes (they go into assertionStrategy.FailureMessages not the local counter). Fix: return failureCount + assertionStrategy.FailureMessages.Count(). Also call scope.Discard() after counting to prevent leaking into the outer scope.

Combined effect

BottleneckBeforeAfter
Phase 3 algorithmO(n!) permutationsO(n^2 log n) greedy (n > 8)
FailureMessageFormatter (Phases 1+2)~3.25M calls0
Regex.Replace (Phases 1+2)~6.5M calls0
Index ToString allocations~3.25MStatic cache 0-1023
CyclicReferenceDetectorShared, unboundedCloned per dry-run

Microbenchmark BeEquivalentToWithDeeplyNestedStructures

Before

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01639.20 us0.775 us1.634 us9.27730.4883-116.18 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21666.98 us1.313 us1.512 us20.38571.0986-125.65 KB
BeEquivalentTo.NET 8.0.NET 8.086283.77 us5.666 us13.243 us68.35945.8594-838.52 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286482.34 us6.367 us5.956 us146.484414.1602-901.14 KB
BeEquivalentTo.NET 8.0.NET 8.010063,410.08 us44.714 us41.826 us843.7500277.3438-10337.69 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210066,046.85 us95.444 us89.279 us1804.6875398.4375-11099.02 KB
BeEquivalentTo.NET 8.0.NET 8.0500617,756.40 us324.924 us333.673 us4187.500031.2500-51622.43 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500630,578.22 us560.673 us524.454 us9000.0000312.5000125.000055414.37 KB

After

MethodJobRuntimeNDepthMeanErrorStdDevGen0Gen1Gen2Allocated
BeEquivalentTo.NET 8.0.NET 8.01635.61 us0.707 us1.380 us9.03320.4883-111.83 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.21660.76 us1.203 us1.181 us19.53130.9766-120.7 KB
BeEquivalentTo.NET 8.0.NET 8.086245.86 us4.754 us7.943 us64.45315.8594-803.77 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.286429.76 us5.432 us5.081 us140.136711.7188-861.65 KB
BeEquivalentTo.NET 8.0.NET 8.010063,039.03 us40.167 us37.572 us804.6875113.2813-9906.72 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.210065,232.71 us100.977 us108.045 us1726.5625179.6875-10608.83 KB
BeEquivalentTo.NET 8.0.NET 8.0500615,162.26 us248.119 us232.090 us4031.2500546.875031.250049477.68 KB
BeEquivalentTo.NET Framework 4.7.2.NET Framework 4.7.2500626,038.15 us414.944 us367.837 us8593.7500781.2500-52979.31 KB

PerformanceSpecs / Compare_complex_collection

Before
37-39 seconds

After
12-13 seconds

@dennisdoomen
dennisdoomen marked this pull request as draft April 6, 2026 07:38
@github-actions

github-actionsBot commented Apr 6, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 4 times, most recently from 9973452 to 75ccee2CompareApril 6, 2026 16:01
@dennisdoomendennisdoomen changed the title perf: significantly speed up BeEquivalentTo for large unordered collectionsSignificantly speed up BeEquivalentTo for large unordered collectionsApr 6, 2026
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch 3 times, most recently from d141c65 to e0e1ef5CompareApril 8, 2026 19:50
dennisdoomenand others added 5 commits April 8, 2026 22:07
…collections
Replace the O(n! * n) permutation search in FindClosestMismatches with a
two-strategy approach:
- For n <= 8 items remaining, keep the exact permutation search (globally
optimal; factorial(8) = 40,320, well within cost).
- For n > 8, use a greedy O(n^2 log n) assignment that sorts all n^2
(expectation, subject) pairs by failure count and picks the closest
unassigned pair first. Ties are broken by expectation index then subject
index to preserve deterministic, natural-order error messages.
Also extract ReferentialComparer to its own file and bump Reflectify to 1.9.0.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AsCollectionItem<T>(int) now returns a pre-computed string for indices 0-1023
instead of calling ToString() on every comparison. For large collections this
avoids repeated short-lived string allocations in hot paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add UseDryRun flag to AssertionScope. When set, AssertionChain.FailWith()
increments a counter instead of formatting and storing the full message.
LooselyOrderedEquivalencyStrategy uses a dry-run scope in TryToMatchCount
to count failures cheaply, split caches (countCache for counts, fullFailuresCache
for strings), and updates FindClosestMismatches to take separate getFailureCount
and getFullFailures delegates — deferring string allocation to the winning
assignment only.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…k overflows
Dry-run comparisons in TryToMatchCount and TryToMatch share the parent
context's CyclicReferenceDetector by default. When the same object appears
in multiple positions in an unordered collection, the detector could flag
subsequent dry-runs as cyclic references and skip them — producing wrong
failure counts and potential stack overflows. Fix by cloning the detector
before each dry-run invocation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetFailureCount() was only counting direct RegisterFailure() calls.
Failures from nested assertion scopes (e.g. via AssertionRuleEquivalencyStep)
are stored in assertionStrategy.FailureMessages rather than the local counter,
so they were invisible to TryToMatchCount — causing mismatches to look like
matches (count=0) and corrupting the optimal assignment.
Also call scope.Discard() after GetFailureCount() so that the dry-run scope
does not propagate its failure messages to the parent scope on disposal.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomenforce-pushed the perf/speed-up-be-equivalent-to branch from e0e1ef5 to 4883856CompareApril 8, 2026 20:08
@dennisdoomen
dennisdoomen marked this pull request as ready for review April 8, 2026 20:08
@dennisdoomendennisdoomen added this to the 8.10 milestone Apr 8, 2026
@dennisdoomen
dennisdoomen requested a review from jnyrupApril 8, 2026 20:16

@jnyrupjnyrup left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good - and what a pleasure to review with the focused commits ♥️

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Comment threadSrc/FluentAssertions/Equivalency/Steps/LooselyOrderedEquivalencyStrategy.cs Outdated
@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Looks good - and what a pleasure to review with the focused commits

I'm using Copilot CLI heavily, so thanks to Copilot ;-)

For later work, I'm wondering if we might be able to utilize UseDryRun more places?

Most likely. But haven't investigated that yet.

@dennisdoomen

Copy link
Copy Markdown
MemberAuthor

Did you do a benchmark on Compare_complex_collection to get before and after cpu/mem numbers?

Added all the benchmarks.

The strategy already owns TryToMatchCount and TryToMatch, so keeping the closest-match helper chain static forced delegate plumbing without adding value. Make those helpers instance methods so they can use the existing instance members directly while preserving the matching logic, scoring, and caches.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dennisdoomen
dennisdoomen merged commit f429c2e into fluentassertions:mainApr 12, 2026
8 checks passed
@dennisdoomen
dennisdoomen deleted the perf/speed-up-be-equivalent-to branch April 12, 2026 13:18
This was referenced Aug 30, 2026
hannesbarbez pushed a commit to hannesbarbez/BarbezDotEu.Playwright that referenced this pull request Sep 3, 2026
Updated
[FluentAssertions](https://github.com/fluentassertions/fluentassertions)
from 8.2.0 to 8.10.0.
<details>
<summary>Release notes</summary>
_Sourced from [FluentAssertions's
releases](https://github.com/fluentassertions/fluentassertions/releases)._
## 8.10.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Fail with a descriptive error when path-based rules are used on
value-semantic types by @​dennisdoomen in
fluentassertions/fluentassertions#3187
* Significantly speed up BeEquivalentTo for large unordered collections
by @​dennisdoomen in
fluentassertions/fluentassertions#3188
* Add ComparingNullCollectionsAsEmpty and ComparingNullStringsAsEmpty
options to BeEquivalentTo by @​dennisdoomen in
fluentassertions/fluentassertions#3202
* Include original index in extraneous item failure messages by
@​dennisdoomen in
fluentassertions/fluentassertions#3203
### Documentation
* Reroute the docs link to Xceed by @​dennisdoomen in
fluentassertions/fluentassertions#3183
* Fix typo in release notes by @​jnyrup in
fluentassertions/fluentassertions#3194
* Fix typos in docs by @​jnyrup in
fluentassertions/fluentassertions#3197
### Others
* Bump flatted from 3.4.1 to 3.4.2 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3184
* Add AI assistant instruction file (agents.md) for Copilot, Claude, and
JetBrains Junie by @​Copilot in
fluentassertions/fluentassertions#3176
* Bump smol-toml from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1
directory by @​dependabot[bot] in
fluentassertions/fluentassertions#3185
* Bump the npm_and_yarn group across 1 directory with 2 updates by
@​dependabot[bot] in
fluentassertions/fluentassertions#3186
* Bump cspell from 9.7.0 to 10.0.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3189
* Update nugets by @​jnyrup in
fluentassertions/fluentassertions#3192
* Fixup Qodana issues by @​jnyrup in
fluentassertions/fluentassertions#3193
* Fix Qodana argument separator by @​jnyrup in
fluentassertions/fluentassertions#3195
* Use new Qodana linter option by @​jnyrup in
fluentassertions/fluentassertions#3196
* Fix flaky BeLessThanOrEqualTo execution time test by @​Copilot in
fluentassertions/fluentassertions#3200
* Bump JetBrains/qodana-action from 2025.3 to 2026.1 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3201
* Use long for hashCode in ReferentialComparer to avoid overflow by
@​dennisdoomen in
fluentassertions/fluentassertions#3204
**Full Changelog**:
fluentassertions/fluentassertions@8.9.0...8.10.0
## 8.9.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
> [!WARNING]
> For projects targeting .NET 9, you need at least .NET SDK 9.0.200 as
earlier versions will trigger compile errors because of invalid overload
resolution. See #​3225
## What's Changed
### New features
* Add support for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>` and
`ReadOnlyMemory<T>` by @​dennisdoomen in
fluentassertions/fluentassertions#3172
### Improvements
* Allow excluding all properties by type from `BeEquivalentTo` by
@​Copilot in
fluentassertions/fluentassertions#3115
* Clean-up the stack trace when throwing an assertion failure by
@​dennisdoomen in
fluentassertions/fluentassertions#3152
* Improve reporting the differences between differently sized
collections in `BeEquivalentTo` by @​dennisdoomen in
fluentassertions/fluentassertions#3133
* Improve reporting the subject when chaining `Throw` and `Which` by
@​dennisdoomen in
fluentassertions/fluentassertions#3160
* Add `HaveMillisecond`/`NotHaveMillisecond` assertion methods for
`DateTime` and `DateTimeOffset` by @​Copilot in
fluentassertions/fluentassertions#3164
* Add `BeEqualTo` and `NotBeEqualTo` as collection assertion aliases by
@​Copilot in
fluentassertions/fluentassertions#3166
### Fixes
* Fix formatting exception when comparing strings containing braces by
@​Copilot in
fluentassertions/fluentassertions#3151
### Documentation
* Also mention the global configuration options in the docs. by
@​dennisdoomen in
fluentassertions/fluentassertions#3132
* Add xUnit migration tips by @​fuguiKz in
fluentassertions/fluentassertions#3141
* Point the docs to the new site by @​dennisdoomen in
fluentassertions/fluentassertions#3155
* Added missing release notes by @​dennisdoomen in
fluentassertions/fluentassertions#3161
* Fix "an" vs "a" typos by @​jnyrup in
fluentassertions/fluentassertions#3174
### Others
* Bump actions/download-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3116
* Bump cspell from 9.2.1 to 9.2.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3118
* Bump actions/upload-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3117
* Add NuGet package attestations using GitHub provenance by @​Copilot in
fluentassertions/fluentassertions#3119
* Bump cspell from 9.2.2 to 9.3.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3122
* Bump actions/attest-build-provenance from 2 to 3 by @​dependabot[bot]
in fluentassertions/fluentassertions#3121
* fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3123
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3124
* Bump cspell from 9.3.0 to 9.3.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3125
* Bump actions/checkout from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3127
* Update to NET 10 SDK by @​jnyrup in
fluentassertions/fluentassertions#3128
* Use `==` or `!=` when comparing Nullable<T> against constants by
@​jnyrup in
fluentassertions/fluentassertions#3129
* Create polyfill for `string.Create` by @​jnyrup in
fluentassertions/fluentassertions#3130
* Bump cspell from 9.3.2 to 9.4.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3131
* Bump actions/cache from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3136
* Bump actions/upload-artifact from 5 to 6 by @​dependabot[bot] in
fluentassertions/fluentassertions#3135
* Bump actions/download-artifact from 6 to 7 by @​dependabot[bot] in
fluentassertions/fluentassertions#3134
* Cleanups by @​jnyrup in
fluentassertions/fluentassertions#3137
* Nuget updates by @​jnyrup in
fluentassertions/fluentassertions#3139
* Suppress `UnassignedGetOnlyAutoProperty` for `Node.GetHashCode` by
@​jnyrup in
fluentassertions/fluentassertions#3138
* Use `NonReadonlyMemberInGetHashCode` by @​jnyrup in
fluentassertions/fluentassertions#3140
* Bump JetBrains/qodana-action from 2025.2 to 2025.3 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3142
* Use compiler-generated `paramName` parameter by @​jnyrup in
fluentassertions/fluentassertions#3143
* Fix `When_concurrently_getting_equality_strategy_it_should_not_throw`
by @​jnyrup in
fluentassertions/fluentassertions#3144
* Bump cspell from 9.4.0 to 9.6.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3146
* Bump cspell from 9.6.0 to 9.6.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3153
... (truncated)
## 8.8.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Add support for MSTest4 by @​jnyrup in
fluentassertions/fluentassertions#3111
### Improvements
* Allow WithoutMessage when using Should().Throw() and ThrowAsync() by
@​dennisdoomen in
fluentassertions/fluentassertions#3100
* Improve reporting of the differences between long strings by
@​dennisdoomen in
fluentassertions/fluentassertions#3101
* Allow customizing the equivalency behavior for BeXmlSerializable by
@​logiclrd in
fluentassertions/fluentassertions#3107
### Documentation
* Add docs for `config` parameter by @​jnyrup in
fluentassertions/fluentassertions#3104
### Others
* Refreshed readme by @​dennisdoomen in
fluentassertions/fluentassertions#3106
* Merge hotfix 8.7.1 back to main by @​dennisdoomen in
fluentassertions/fluentassertions#3109
* Fix release notes by @​jnyrup in
fluentassertions/fluentassertions#3112
* Bump github/codeql-action from 3 to 4 by @​dependabot[bot] in
fluentassertions/fluentassertions#3113
**Full Changelog**:
fluentassertions/fluentassertions@8.7.1...8.8.0
## 8.7.1
<!-- Release notes generated using configuration in .github/release.yml
at hotfix/8.7.1 -->
## What's Changed
### Others
* JSON assertions did not properly handle floats, doubles and unsigned …
by @​dennisdoomen in
fluentassertions/fluentassertions#3105
* Fixed ambiguity when using Should on a JsonNode derived class ... by
@​JSkimming in
fluentassertions/fluentassertions#3102
**Full Changelog**:
fluentassertions/fluentassertions@8.7.0...8.7.1
## 8.7.0
<!-- Release notes generated using configuration in .github/release.yml
at ae620add07cf6666841e568fd3bf8a0733478bb5 -->
## What's Changed
### New features
* Added System.Text.Json assertion APIs and BeEquivalentTo support by
@​dennisdoomen in
fluentassertions/fluentassertions#3094
### Others
* Address a bunch of Qodana issues by @​dennisdoomen in
fluentassertions/fluentassertions#3082
* Bump actions/checkout from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3085
* Bump JetBrains/qodana-action from 2025.1 to 2025.2 by
@​dependabot[bot] in
fluentassertions/fluentassertions#3086
* Bump actions/download-artifact from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3087
* Bump cspell from 9.2.0 to 9.2.1 by @​dependabot[bot] in
fluentassertions/fluentassertions#3090
* Bump actions/setup-dotnet from 4 to 5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3092
* Add lock file for nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3084
* Set `DisableImplicitNuGetFallbackFolder` by @​jnyrup in
fluentassertions/fluentassertions#3095
* Bump Nugets by @​jnyrup in
fluentassertions/fluentassertions#3096
* Revert package locking by @​jnyrup in
fluentassertions/fluentassertions#3098
* Clean-up tests related to exceptions by @​dennisdoomen in
fluentassertions/fluentassertions#3099
**Full Changelog**:
fluentassertions/fluentassertions@8.6.0...8.7.0
## 8.6.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Add support for inline assertions using `Value.ThatMatches` and
`Value.ThatSatisfies` by @​dennisdoomen in
fluentassertions/fluentassertions#3076
### Others
* Remove Microsoft.SourceLink.GitHub by @​SimonCropp in
fluentassertions/fluentassertions#3072
* Bump cspell from 9.1.3 to 9.1.5 by @​dependabot[bot] in
fluentassertions/fluentassertions#3073
* Added PackageGuard to the build pipeline by @​dennisdoomen in
fluentassertions/fluentassertions#3075
* Bump cspell from 9.1.5 to 9.2.0 by @​dependabot[bot] in
fluentassertions/fluentassertions#3077
* Remove dependencies on Bogus by @​jnyrup in
fluentassertions/fluentassertions#3080
* Update nuget packages by @​jnyrup in
fluentassertions/fluentassertions#3081
* Follow-up to #​3076 by @​jnyrup in
fluentassertions/fluentassertions#3079
* Documentation and typo fixes by @​jnyrup in
fluentassertions/fluentassertions#3078
## New Contributors
* @​SimonCropp made their first contribution in
fluentassertions/fluentassertions#3072
**Full Changelog**:
fluentassertions/fluentassertions@8.5.0...8.6.0
## 8.5.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### New features
* Extended BeEquivalentTo with support for checking the type of the
properties by @​dennisdoomen in
fluentassertions/fluentassertions#3066
### Fixes
* Ensured WithTracing is safe when used with BeEquivalentTo globally by
@​dennisdoomen in
fluentassertions/fluentassertions#3067
### Others
* Revisit the .editorconfig rules by @​dennisdoomen in
fluentassertions/fluentassertions#3063
* Bump cspell from 9.1.1 to 9.1.2 by @​dependabot[bot] in
fluentassertions/fluentassertions#3068
* Use .NET 9 SDK by @​jnyrup in
fluentassertions/fluentassertions#3069
* Bump cspell from 9.1.2 to 9.1.3 by @​dependabot[bot] in
fluentassertions/fluentassertions#3071
**Full Changelog**:
fluentassertions/fluentassertions@8.4.0...8.5.0
## 8.4.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Allow excluding properties by name anywhere in the graph by
@​dennisdoomen in
fluentassertions/fluentassertions#3062
* Added Should().Throw(), ThrowAsync() and ThrowWithinAsync() flavors
that don’t require a specific exception type by @​Xceed-DelvaJB in
fluentassertions/fluentassertions#3059
### Others
* Bump cspell from 9.0.1 to 9.0.2 by @​dependabot in
fluentassertions/fluentassertions#3050
* Bump JetBrains/qodana-action from 2024.2 to 2025.1 by @​dependabot in
fluentassertions/fluentassertions#3044
* Fix qodana warnings by @​jnyrup in
fluentassertions/fluentassertions#3053
* Add contributor grant by @​dennisdoomen in
fluentassertions/fluentassertions#3055
* Bump cspell from 9.0.2 to 9.1.1 by @​dependabot in
fluentassertions/fluentassertions#3058
## New Contributors
* @​Xceed-DelvaJB made their first contribution in
fluentassertions/fluentassertions#3059
**Full Changelog**:
fluentassertions/fluentassertions@8.3.0...8.4.0
## 8.3.0
<!-- Release notes generated using configuration in .github/release.yml
at main -->
## What's Changed
### Improvements
* Improve rendering of exception messages when using WithMessage by
@​dennisdoomen in
fluentassertions/fluentassertions#3039
* Clarify the date/time type when comparing dates, times and
combinations of those by @​dennisdoomen in
fluentassertions/fluentassertions#3049
### Others
* Bump cspell from 8.17.5 to 8.18.1 by @​dependabot in
fluentassertions/fluentassertions#3041
* Bump cspell from 8.18.1 to 8.19.2 by @​dependabot in
fluentassertions/fluentassertions#3042
* Bump cspell from 8.19.2 to 8.19.3 by @​dependabot in
fluentassertions/fluentassertions#3043
* Bump cspell from 8.19.3 to 9.0.0 by @​dependabot in
fluentassertions/fluentassertions#3045
* Bump cspell from 9.0.0 to 9.0.1 by @​dependabot in
fluentassertions/fluentassertions#3046
* Fix links to test suites in bug report template by @​robvanuden in
fluentassertions/fluentassertions#3047
**Full Changelog**:
fluentassertions/fluentassertions@8.2.0...8.3.0
Commits viewable in [compare
view](fluentassertions/fluentassertions@8.2.0...8.10.0).
</details>
[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=FluentAssertions&package-manager=nuget&previous-version=8.2.0&new-version=8.10.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
<details>
<summary>Dependabot commands and options</summary>
<br />
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
</details>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@dennisdoomen@jnyrup