Skip to content

perf: reduce allocations in EventReceiverOrchestrator and TestContextExtensions#5212

Merged
thomhurst merged 2 commits intomainfrom
perf/reduce-event-receiver-allocations
Mar 22, 2026
Merged

perf: reduce allocations in EventReceiverOrchestrator and TestContextExtensions#5212
thomhurst merged 2 commits intomainfrom
perf/reduce-event-receiver-allocations

Conversation

@thomhurst
Copy link
Owner

Summary

  • Remove redundant .ToArray() in SortAndFilter: FilterScopedAttributes already returns T[], so calling .ToArray() on the result was creating an unnecessary array copy on every call
  • Eliminate double LINQ sort in InvokeHookRegistrationEventReceiversAsync: Was calling .OrderBy() before FilterScopedAttributes AND .OrderBy() after it. Replaced with a single in-place Array.Sort after filtering
  • Add TryGetValue fast path before GetOrAdd in InvokeFirstTestInSession/Assembly/Class: The GetOrAdd lambda captures context, sessionContext/assemblyContext/classContext, and cancellationToken, causing a closure+delegate allocation on every call even when the key exists. The manual TryGetValue check avoids this allocation on the common fast path (all tests after the first)
  • Make sort comparison lambda static in SortAndFilter to prevent implicit capture

Test plan

  • Verify build succeeds across all target frameworks (net8.0, net9.0, net10.0, netstandard2.0)
  • Run existing event receiver tests to confirm no behavior changes
  • All changes are allocation-only optimizations with no behavioral impact

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

Overall this is a solid set of allocation-reduction improvements aligned with TUnit's performance-first principle. The changes are well-explained and targeted. A few observations:

static lambda in SortAndFilter (TestContextExtensions.cs)

Good catch — static lambdas are guaranteed not to capture state and avoid the display class allocation. The compiler was likely already doing this, but making it explicit is a good self-documenting constraint.

✅ Remove redundant .ToArray() in SortAndFilter

Correct. FilterScopedAttributes already returns T[] (via vlb.AsSpan().ToArray()), so the .ToArray() call was doubling up on array allocation unnecessarily. Clean fix.

TryGetValue fast path before GetOrAdd (session/assembly/class)

The reasoning is sound — ConcurrentDictionary.GetOrAdd(key, valueFactory) always allocates a closure/delegate for the factory even when the key already exists (since the factory delegate must be constructed before the call). The TryGetValue short-circuit avoids that on the hot path (every test after the first). Thread-safety is preserved: ConcurrentDictionary is safe for concurrent reads, and if two threads race past TryGetValue, GetOrAdd still correctly returns the same stored Task.

⚠️ Potential behavioral change: pre-sort removal in InvokeHookRegistrationEventReceiversAsync

The PR description says "all changes are allocation-only optimizations with no behavioral impact", but removing the .OrderBy() before FilterScopedAttributes can affect which scoped attribute is retained when there are multiple IHookRegisteredEventReceiver attributes sharing the same IScopedAttribute.ScopeType.

FilterScopedAttributes uses TryAdd — it keeps the first encountered:

scopedAttributesByType.TryAdd(scopedAttribute.ScopeType, item);
  • Before: input was pre-sorted by Order, so the attribute with the lowest Order was kept.
  • After: input is in declaration/attribute-collection order, so the attribute appearing first in the array is kept.

For the typical case (one attribute per scope type per hook), this makes no difference. But if two scoped attributes of the same type are applied to a hook with different Order values, the one that "wins" may change.

This is likely a rare or contrived scenario in practice, but since the PR description claims no behavioral change, it's worth acknowledging or verifying that the pre-sort was truly vestigial (e.g., if hookContext.HookMethod.Attributes is already ordered by declaration position and scoped types are enforced as unique by design).

If the existing pre-sort behavior (lowest Order wins for scoped deduplication) was intentional, consider:

// Sort first so lowest-Order wins during scoped deduplication
hookContext.HookMethod.Attributes
    .OfType<IHookRegisteredEventReceiver>()
    .OrderBy(static r => r.Order)

...then pass the sorted sequence to FilterScopedAttributes, and skip the post-Array.Sort (since the output order is already determined).

Or, if ordering within scoped deduplication is unimportant, document that assumption explicitly.

Summary

Three of the four changes are clearly correct and safe. The fourth (removing the pre-sort in InvokeHookRegistrationEventReceiversAsync) is very likely fine in practice but is technically a behavioral change in an edge case that the PR description doesn't account for. Worth a quick confirmation that hook attributes of the same scoped type per hook are either impossible by design or that declaration order is acceptable as the tiebreaker.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

Summary

This is a focused, well-reasoned allocation reduction PR. The changes are correct and the PR description accurately documents the intent. A few observations worth noting:


— removal ✅

Clean fix. already returns , so the redundant copy was pure waste. The lambda annotation is also a good catch to prevent implicit capture.


— double sort ✅ (with a note)

The pre-sort → filter → post-sort pattern is correct and necessary, and the comment explains it well. Worth explicitly noting the semantic improvement: the old code used order (arbitrary) to determine which scoped attribute was kept during deduplication, then sorted after. The new code pre-sorts by Order so that FilterScopedAttributes's TryAdd-first-wins behavior now deterministically retains the lowest-Order scoped attribute per ScopeType. This is a subtle but meaningful correctness improvement, not just a perf change.

One thing to be aware of: FilterScopedAttributes appends scoped items from a Dictionary<Type, T>, which in .NET has implementation-defined iteration order. The post-filter Array.Sort handles this correctly by re-sorting everything, so the behavior is well-defined. No issue here, just worth being aware of.


+ fast path ✅

The pattern is correct and this is the canonical way to avoid closure allocation on the hot path with . A few things to confirm:

  1. Race safety: If two threads both fail , they both fall through to . 's factory may be called twice, producing two instances, but only one is stored — the other is abandoned. This is a pre-existing characteristic of the original usage, not introduced by this PR. It's fine here because the tasks are idempotent in effect (receivers are invoked once per task, but the abandoned task is never awaited).

  2. The optimization is valid: On the common path (2nd+ test in session/assembly/class), returns true and the closure + delegate allocation never happens. The savings are real.


Minor

The attributes already present on the three methods pair well with this pattern since the fast path is now just a single dictionary lookup + .


Overall this is a clean, low-risk perf improvement. The changes are well-documented and the optimizations are all valid. No issues to block merging.

Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

Summary

This is a focused, well-reasoned allocation reduction PR. The changes are correct and the PR description accurately documents the intent. A few observations:


SortAndFilter.ToArray() removal ✅

Clean fix. FilterScopedAttributes already returns T[], so the redundant .ToArray() copy was pure waste. The static lambda annotation is also a good catch.


InvokeHookRegistrationEventReceiversAsync — double sort ✅ (with a note)

The pre-sort → filter → post-sort pattern is correct and necessary, and the comment explains it well. Worth noting explicitly: this is a subtle semantic improvement too, not just a perf change.

The old code used .OfType<>() iteration order (arbitrary) to determine which scoped attribute was retained during deduplication. The new code pre-sorts by Order so that FilterScopedAttributes's TryAdd-first-wins behavior now deterministically retains the lowest-Order scoped attribute per ScopeType. This is more correct behavior.

One thing to be aware of: FilterScopedAttributes appends scoped items from a Dictionary<Type, T> (implementation-defined iteration order), so their relative ordering is undefined after filtering. The post-filter Array.Sort re-sorts everything, which handles this correctly. No issue — just worth understanding the invariant.


TryGetValue + GetOrAdd fast path ✅

The pattern is correct. A few things worth noting:

  1. Race safety: If two threads both fail TryGetValue, they both fall through to GetOrAdd. The factory delegate may be called twice, producing two Task instances — but only one is stored and the other is abandoned. This is a pre-existing property of GetOrAdd, not introduced here, and it's acceptable because the abandoned task is never awaited.

  2. The optimization is valid: On the common path (2nd+ test in session/assembly/class), TryGetValue returns true and the closure allocation is avoided entirely. Given these methods are hot paths, the savings are real.

The AggressiveInlining attributes already on these methods pair nicely with this pattern — the fast path is now just a dictionary lookup + new ValueTask(existing).


Overall

Clean, low-risk performance improvement. The changes are well-documented and all optimizations are valid. No concerns blocking merge.

…Extensions

Eliminate unnecessary allocations on hot paths during test execution:

- Remove redundant .ToArray() in SortAndFilter since FilterScopedAttributes
  already returns T[]
- Remove double LINQ OrderBy in InvokeHookRegistrationEventReceiversAsync,
  replace with in-place Array.Sort
- Add TryGetValue fast path before GetOrAdd in first-test-in-session/assembly/class
  methods to avoid closure allocation when the key already exists
- Make sort comparison lambda static to prevent implicit capture
@thomhurst thomhurst force-pushed the perf/reduce-event-receiver-allocations branch from b235fe5 to 019db42 Compare March 22, 2026 12:37
Copy link
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Follow-up Review

The author has addressed the behavioral concern raised in my previous review by restoring the .OrderBy(static x => x.Order) pre-sort before passing attributes to FilterScopedAttributes. The updated comment also clearly explains the semantic intent:

"Pre-sort by Order before filtering so that FilterScopedAttributes (which uses TryAdd and keeps the first encountered item per ScopeType) retains the lowest-Order attribute."

All changes now look correct ✅

  1. static lambda in SortAndFilter — prevents implicit capture, good self-documenting constraint.
  2. Redundant .ToArray() removal — correct, FilterScopedAttributes already returns T[].
  3. TryGetValue fast path before GetOrAdd — avoids closure allocation on the hot path (every test after the first), thread-safe.
  4. Pre-sort → filter → in-place sort in InvokeHookRegistrationEventReceiversAsync — the pre-sort now correctly ensures the lowest-Order attribute wins during scoped deduplication, and the post-sort handles the dictionary iteration order indeterminacy from FilterScopedAttributes.

No concerns. This is a clean, low-risk allocation reduction that aligns well with TUnit's performance-first principle. Ready to merge.

This was referenced Mar 23, 2026
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 23, 2026
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.19.57 to
1.21.6.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.21.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.6 -->

## What's Changed
### Other Changes
* perf: replace object locks with Lock type for efficient
synchronization by @​thomhurst in
thomhurst/TUnit#5219
* perf: parallelize test metadata collection for source-generated tests
by @​thomhurst in thomhurst/TUnit#5221
* perf: use GetOrAdd args overload to eliminate closure allocations in
event receivers by @​thomhurst in
thomhurst/TUnit#5222
* perf: self-contained TestEntry<T> with consolidated switch invokers
eliminates per-test JIT by @​thomhurst in
thomhurst/TUnit#5223
### Dependencies
* chore(deps): update tunit to 1.21.0 by @​thomhurst in
thomhurst/TUnit#5220


**Full Changelog**:
thomhurst/TUnit@v1.21.0...v1.21.6

## 1.21.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.0 -->

## What's Changed
### Other Changes
* perf: reduce ConcurrentDictionary closure allocations in hot paths by
@​thomhurst in thomhurst/TUnit#5210
* perf: reduce async state machine overhead in test execution pipeline
by @​thomhurst in thomhurst/TUnit#5214
* perf: reduce allocations in EventReceiverOrchestrator and
TestContextExtensions by @​thomhurst in
thomhurst/TUnit#5212
* perf: skip timeout machinery when no timeout configured by @​thomhurst
in thomhurst/TUnit#5211
* perf: reduce allocations and lock contention in ObjectTracker by
@​thomhurst in thomhurst/TUnit#5213
* Feat/numeric tolerance by @​agray in
thomhurst/TUnit#5110
* perf: remove unnecessary lock in ObjectTracker.TrackObjects by
@​thomhurst in thomhurst/TUnit#5217
* perf: eliminate async state machine in
TestCoordinator.ExecuteTestAsync by @​thomhurst in
thomhurst/TUnit#5216
* perf: eliminate LINQ allocation in ObjectTracker.UntrackObjectsAsync
by @​thomhurst in thomhurst/TUnit#5215
* perf: consolidate module initializers into single .cctor via partial
class by @​thomhurst in thomhurst/TUnit#5218
### Dependencies
* chore(deps): update tunit to 1.20.0 by @​thomhurst in
thomhurst/TUnit#5205
* chore(deps): update dependency nunit3testadapter to 6.2.0 by
@​thomhurst in thomhurst/TUnit#5206
* chore(deps): update dependency cliwrap to 3.10.1 by @​thomhurst in
thomhurst/TUnit#5207


**Full Changelog**:
thomhurst/TUnit@v1.20.0...v1.21.0

## 1.20.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.20.0 -->

## What's Changed
### Other Changes
* Fix inverted colors in HTML report ring chart due to locale-dependent
decimal formatting by @​Copilot in
thomhurst/TUnit#5185
* Fix nullable warnings when using Member() on nullable properties by
@​Copilot in thomhurst/TUnit#5191
* Add CS8629 suppression and member access expression matching to
IsNotNullAssertionSuppressor by @​Copilot in
thomhurst/TUnit#5201
* feat: add ConfigureAppHost hook to AspireFixture by @​thomhurst in
thomhurst/TUnit#5202
* Fix ConfigureTestConfiguration being invoked twice by @​thomhurst in
thomhurst/TUnit#5203
* Add IsEquivalentTo assertion for Memory<T> and ReadOnlyMemory<T> by
@​thomhurst in thomhurst/TUnit#5204
### Dependencies
* chore(deps): update dependency gitversion.tool to v6.6.2 by
@​thomhurst in thomhurst/TUnit#5181
* chore(deps): update dependency gitversion.msbuild to 6.6.2 by
@​thomhurst in thomhurst/TUnit#5180
* chore(deps): update tunit to 1.19.74 by @​thomhurst in
thomhurst/TUnit#5179
* chore(deps): update verify to 31.13.3 by @​thomhurst in
thomhurst/TUnit#5182
* chore(deps): update verify to 31.13.5 by @​thomhurst in
thomhurst/TUnit#5183
* chore(deps): update aspire to 13.1.3 by @​thomhurst in
thomhurst/TUnit#5189
* chore(deps): update dependency stackexchange.redis to 2.12.4 by
@​thomhurst in thomhurst/TUnit#5193
* chore(deps): update microsoft/setup-msbuild action to v3 by
@​thomhurst in thomhurst/TUnit#5197


**Full Changelog**:
thomhurst/TUnit@v1.19.74...v1.20.0

## 1.19.74

<!-- Release notes generated using configuration in .github/release.yml
at v1.19.74 -->

## What's Changed
### Other Changes
* feat: per-hook activity spans with method names by @​thomhurst in
thomhurst/TUnit#5159
* fix: add tooltip to truncated span names in HTML report by @​thomhurst
in thomhurst/TUnit#5164
* Use enum names instead of numeric values in test display names by
@​Copilot in thomhurst/TUnit#5178
* fix: resolve CS8920 when mocking interfaces whose members return
static-abstract interfaces by @​lucaxchaves in
thomhurst/TUnit#5154
### Dependencies
* chore(deps): update tunit to 1.19.57 by @​thomhurst in
thomhurst/TUnit#5157
* chore(deps): update dependency gitversion.msbuild to 6.6.1 by
@​thomhurst in thomhurst/TUnit#5160
* chore(deps): update dependency gitversion.tool to v6.6.1 by
@​thomhurst in thomhurst/TUnit#5161
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5163
* chore(deps): update dependency polyfill to 9.20.0 by @​thomhurst in
thomhurst/TUnit#5162
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5166
* chore(deps): update dependency polyfill to 9.21.0 by @​thomhurst in
thomhurst/TUnit#5167
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5168
* chore(deps): update dependency polyfill to 9.22.0 by @​thomhurst in
thomhurst/TUnit#5169
* chore(deps): update dependency coverlet.collector to 8.0.1 by
@​thomhurst in thomhurst/TUnit#5177

## New Contributors
* @​lucaxchaves made their first contribution in
thomhurst/TUnit#5154

**Full Changelog**:
thomhurst/TUnit@v1.19.57...v1.19.74

Commits viewable in [compare
view](thomhurst/TUnit@v1.19.57...v1.21.6).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.19.57&new-version=1.21.6)](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 free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant