Conversation
There was a problem hiding this comment.
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 lowestOrderwas 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.
There was a problem hiding this comment.
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:
-
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).
-
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.
There was a problem hiding this comment.
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:
-
Race safety: If two threads both fail
TryGetValue, they both fall through toGetOrAdd. The factory delegate may be called twice, producing twoTaskinstances — but only one is stored and the other is abandoned. This is a pre-existing property ofGetOrAdd, not introduced here, and it's acceptable because the abandoned task is never awaited. -
The optimization is valid: On the common path (2nd+ test in session/assembly/class),
TryGetValuereturns 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
b235fe5 to
019db42
Compare
There was a problem hiding this comment.
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 ✅
staticlambda inSortAndFilter— prevents implicit capture, good self-documenting constraint.- Redundant
.ToArray()removal — correct,FilterScopedAttributesalready returnsT[]. TryGetValuefast path beforeGetOrAdd— avoids closure allocation on the hot path (every test after the first), thread-safe.- 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 fromFilterScopedAttributes.
No concerns. This is a clean, low-risk allocation reduction that aligns well with TUnit's performance-first principle. Ready to merge.
[//]: # (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> [](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>
Summary
.ToArray()inSortAndFilter:FilterScopedAttributesalready returnsT[], so calling.ToArray()on the result was creating an unnecessary array copy on every callInvokeHookRegistrationEventReceiversAsync: Was calling.OrderBy()beforeFilterScopedAttributesAND.OrderBy()after it. Replaced with a single in-placeArray.Sortafter filteringTryGetValuefast path beforeGetOrAddinInvokeFirstTestInSession/Assembly/Class: TheGetOrAddlambda capturescontext,sessionContext/assemblyContext/classContext, andcancellationToken, causing a closure+delegate allocation on every call even when the key exists. The manualTryGetValuecheck avoids this allocation on the common fast path (all tests after the first)staticinSortAndFilterto prevent implicit captureTest plan