[efficiency-improver] perf: eliminate string[1] allocation per test case in discovery source tracking - #16177
Conversation
… source tracking
MarkSourcesBasedOnDiscoveredTestCases called MarkSourcesWithStatus(new[] { source }, ...)
for every test case in each discovered batch, allocating a temporary single-element
string array per test case. For a 10K-test discovery run, this created ~10K transient
string[1] allocations that added GC pressure.
Add private MarkSourceWithStatus(string, DiscoveryStatus) helper that calls AddOrUpdate
directly. MarkSourcesWithStatus delegates to this helper (DRY). Hot loop in
MarkSourcesBasedOnDiscoveredTestCases now calls MarkSourceWithStatus directly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Refactors DiscoveryDataAggregator source-status tracking in the CrossPlatEngine discovery hot path to eliminate a per-test-case string[1] allocation, reducing GC pressure during large discovery runs.
Changes:
- Extracts a private
MarkSourceWithStatushelper to centralize theConcurrentDictionary.AddOrUpdatelogic. - Updates
MarkSourcesWithStatus(IEnumerable<string?>?, ...)to delegate per source to the helper. - Updates
MarkSourcesBasedOnDiscoveredTestCases(...)to call the helper directly, avoidingnew[] { source }allocations in the per-test-case loop.
| private void MarkSourceWithStatus(string source, DiscoveryStatus status) | ||
| { | ||
| _sourcesWithDiscoveryStatus.AddOrUpdate(source, |
| return status; | ||
| }, | ||
| (_, previousStatus) => | ||
| EqtTrace.Warning($"DiscoveryDataAggregator.MarkSourceWithStatus: Undiscovered {source} added with status: '{status}'."); |
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
Review Summary (DRAFT)
Single file changed: DiscoveryDataAggregator.cs. Activated dimensions: Parallel Execution & Scheduling Safety, Error Reporting & Diagnostic Clarity.
| Dimension | Status |
|---|---|
| Parallel Execution & Scheduling Safety | |
| Error Reporting & Diagnostic Clarity | |
| Process Architecture & Host Resolution | ✅ pass (N/A) |
Findings: 1 warn, 1 nit
The allocation fix itself
The new[] { source } elimination is correct. The extraction of MarkSourceWithStatus is clean, ConcurrentDictionary.AddOrUpdate semantics are preserved, and the public API is unchanged.
The _isMessageSent guard granularity change
This is the one genuine issue. Before the PR, calling MarkSourcesWithStatus(new[] { source }, ...) per iteration meant _isMessageSent was checked once per test case. Now the guard only fires at the top of MarkSourcesBasedOnDiscoveredTestCases, so a concurrent TryAggregateIsMessageSent() + GetSourcesWithStatus() snapshot in ParallelDiscoveryEventsHandler.HandleDiscoveryComplete (lines 82–92) can race with the entire remaining batch. The practical impact is narrow: a source might appear PartiallyDiscovered instead of FullyDiscovered in the final report, which incorrectly trips the "discovery aborted" heuristic on line 106 of that handler. The ConcurrentDictionary is safe; the issue is snapshot consistency. The race existed before (the per-call guard just made the window per-test instead of per-batch), so this isn't a newly introduced bug — but "Identical observable behaviour" overstates it.
The suggestion in the inline comment (if (_isMessageSent == 1) return previousSource inside the loop) restores the per-test guard with no allocation overhead.
Missing test coverage
DiscoveryDataAggregatorRegressionTests.MarkSourcesWithStatus_AfterMessageSent_ShouldSkipUpdate covers MarkSourcesWithStatus + TryAggregateIsMessageSent. There's no equivalent test for MarkSourcesBasedOnDiscoveredTestCases + TryAggregateIsMessageSent. Since the guard behavior of that method changed, a test would pin it.
Trace string rename (nit)
MarkSourcesWithStatus → MarkSourceWithStatus in the per-source trace messages is factually correct, but it's a format change for anyone grepping logs. No test assertions break.
Good goal, clean approach. Address the guard granularity (or explicitly document the intentional coarsening and add a test to pin it) before merging.
🧠 Reviewed by expert-reviewer · PR #16177
🧠 Reviewed by Expert Code Reviewer 🧠
| if (previousSource is null || previousSource == currentSource) | ||
| { | ||
| MarkSourcesWithStatus(new[] { currentSource }, DiscoveryStatus.PartiallyDiscovered); | ||
| MarkSourceWithStatus(currentSource, DiscoveryStatus.PartiallyDiscovered); |
There was a problem hiding this comment.
[Parallel Execution & Scheduling Safety] The _isMessageSent guard granularity regressed from per-test-case to per-batch.
Before this PR, each MarkSourcesWithStatus(new[] { ... }, ...) call checked _isMessageSent at entry. So if TryAggregateIsMessageSent() fired on a concurrent thread mid-batch, the very next iteration caught it.
Now the guard only lives at the top of MarkSourcesBasedOnDiscoveredTestCases. If TryAggregateIsMessageSent() races in after the method enters the loop, the entire remaining batch continues writing to _sourcesWithDiscoveryStatus even though ParallelDiscoveryEventsHandler may already be executing GetSourcesWithStatus() on lines 89–92.
Practical sequence:
- Thread A passes the top-level guard (
_isMessageSent == 0) and begins iterating a batch - Thread B calls
TryAggregateIsMessageSent()(sets_isMessageSent = 1) then immediately callsGetSourcesWithStatus()to build the final report - Thread A continues
MarkSourceWithStatus()for every remaining test case in the batch
Those writes are "post-finalization" updates that weren't reflected in Thread B's snapshot. A source that should have been upgraded from PartiallyDiscovered → FullyDiscovered mid-batch may appear as PartiallyDiscovered in the final report, which then incorrectly triggers the "discovery aborted" path at ParallelDiscoveryEventsHandler line 106.
The ConcurrentDictionary doesn't corrupt, but the "Identical observable behaviour" and "No impact on thread safety" claims in the PR description aren't accurate for this concurrent scenario.
The race existed before (just narrower), so this is not a newly introduced bug — but it is a regression in the guard's effectiveness. The simplest fix is a mid-loop check:
| MarkSourceWithStatus(currentSource, DiscoveryStatus.PartiallyDiscovered); | |
| foreach (var testCase in testCases) | |
| { | |
| if (_isMessageSent == 1) | |
| { | |
| return previousSource; | |
| } | |
| var currentSource = testCase.Source; |
Alternatively, document that the guard was intentionally coarsened to per-batch and add a test for MarkSourcesBasedOnDiscoveredTestCases + TryAggregateIsMessageSent to pin the behaviour (analogous to MarkSourcesWithStatus_AfterMessageSent_ShouldSkipUpdate).
| return status; | ||
| }, | ||
| (_, previousStatus) => | ||
| EqtTrace.Warning($"DiscoveryDataAggregator.MarkSourceWithStatus: Undiscovered {source} added with status: '{status}'."); |
There was a problem hiding this comment.
nit: [Error Reporting & Diagnostic Clarity] All per-source trace messages changed from MarkSourcesWithStatus (plural) to MarkSourceWithStatus (singular). The new name is factually more accurate since the messages now live in the private helper, but it's a format change that would break any grep-based log analysis or support runbooks that match on the old prefix. No test currently asserts on these strings so nothing breaks in CI, but worth noting for ops / support docs.
…DiscoveredTestCases Address code review feedback on PR #16177: - Add mid-loop _isMessageSent check inside MarkSourcesBasedOnDiscoveredTestCases foreach to restore per-test-case guard granularity (was per-batch after the refactor) - Add MarkSourcesBasedOnDiscoveredTestCases_AfterMessageSent_ShouldSkipUpdate test to pin the guard behavior, analogous to MarkSourcesWithStatus_AfterMessageSent_ShouldSkipUpdate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
Jakub Jareš (nohwnd)
left a comment
There was a problem hiding this comment.
Re-review: Previous Findings Addressed ✅
PR #16177 was updated on 2026-06-28 with a follow-up commit (fix: restore per-test-case _isMessageSent guard) that directly responds to the previous expert review. This pass checks that the fixes are correct and complete.
Finding resolution
| Finding (from prior review) | Status |
|---|---|
_isMessageSent guard coarsened from per-test-case to per-batch |
✅ Fixed |
Missing test for MarkSourcesBasedOnDiscoveredTestCases + TryAggregateIsMessageSent |
✅ Fixed |
Trace prefix renamed MarkSourcesWithStatus → MarkSourceWithStatus (nit) |
⚪ Open (nit, no action required) |
Guard fix (correctness verified)
The per-iteration check added inside MarkSourcesBasedOnDiscoveredTestCases:
foreach (var testCase in testCases)
{
if (_isMessageSent == 1)
{
EqtTrace.Verbose("...skipping remaining source updates.");
return previousSource;
}
...
}This restores the per-test-case guard granularity that existed before the PR (previously each MarkSourcesWithStatus(new[] { ... }) call checked at entry; now each loop iteration checks before calling the private helper). The small check → AddOrUpdate race window is identical to the pre-PR behaviour — inherent to this design, not a regression.
New test (correctness verified)
MarkSourcesBasedOnDiscoveredTestCases_AfterMessageSent_ShouldSkipUpdate correctly pins the top-level guard: after TryAggregateIsMessageSent(), a subsequent call returns without upgrading any source status. The test is deterministic and matches the structure of the existing MarkSourcesWithStatus_AfterMessageSent_ShouldSkipUpdate.
Allocation optimization (unchanged, still correct)
The core change — replacing new[] { source } with a direct call to the private MarkSourceWithStatus helper — is clean. ConcurrentDictionary.AddOrUpdate semantics are preserved, public API surface is unchanged.
Note: PR still in DRAFT
The PR is currently in draft state. No code issues remain from this review; the maintainer can undraft when ready (note that undrafting triggers a rebuild per the project workflow guidelines).
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
|
The Azure DevOps CI check The PR touches only Could a maintainer please manually re-trigger the Azure DevOps build? If the re-trigger also fails with a real build error, I'll investigate further.
|
|
The proposed benefit is very little (4MB of allocation/garbage collecting per 10k tests) while introducing potential parallelization problems. Therefore, I decide not to merge this change. |
|
It says 4kb per 10k of memory traffic, so even less. |
Goal and Rationale
Eliminate a transient
string[1]array allocation that occurred once per discovered test case during the test discovery hot path, reducing GC pressure during large test runs.Focus area: Code-Level Efficiency
Problem
MarkSourcesBasedOnDiscoveredTestCaseswas called once per batch of discovered test cases. Inside it, for each individual test case it called:This allocated a temporary single-element
string[]array on every iteration — just to satisfy theIEnumerable<string>parameter of a public method that iterates over it immediately. For a discovery run with 10,000 tests, this created ~10,000 short-livedstring[1]arrays that were immediately eligible for GC collection.Approach
MarkSourceWithStatus(string source, DiscoveryStatus status)helper containing the coreConcurrentDictionary.AddOrUpdatelogic.MarkSourcesWithStatus(IEnumerable<string>, DiscoveryStatus)to delegate to this helper per source — keeps the public API intact and eliminates duplication (DRY).MarkSourcesBasedOnDiscoveredTestCasesto callMarkSourceWithStatusdirectly, bypassing the array allocation entirely.The public API surface is unchanged; no callers needed updating.
Energy Efficiency Evidence
Proxy metric: GC allocation count (fewer short-lived allocations → less GC work → less CPU/DRAM energy spent on collection).
string[1]arrays (~40 bytes each on 64-bit → ~400 KB of GC pressure)string[1]arrays (~4 MB of GC pressure)These are Gen0 allocations (short-lived), but their volume in large test runs forces additional GC collections, each of which suspends all managed threads and burns CPU cycles scanning the heap. Reducing allocation volume is directly proportional to reducing the frequency of these pauses.
Green Software Foundation context:
dotnet testinvocation, directly improving SCI for all users running tests via vstest.Trade-offs
None. This is a pure refactoring:
ConcurrentDictionary.AddOrUpdate)Reproducibility
To observe the allocation reduction, use a memory profiler (dotMemory, BenchmarkDotNet with
[MemoryDiagnoser]) on theMarkSourcesBasedOnDiscoveredTestCasescall path with a large test batch.Test Status
DiscoveryDataAggregatorRegressionTests)