Skip to content

[efficiency-improver] perf: defer GetTestName() to failure branches and avoid OfType<> alloc in AzureDevOpsReporter#9196

Merged
Evangelink merged 4 commits into
mainfrom
efficiency/azdo-reporter-defer-getname-e4bfb2073b204687
Jun 17, 2026
Merged

[efficiency-improver] perf: defer GetTestName() to failure branches and avoid OfType<> alloc in AzureDevOpsReporter#9196
Evangelink merged 4 commits into
mainfrom
efficiency/azdo-reporter-defer-getname-e4bfb2073b204687

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Goal and Rationale

Reduce CPU work and heap allocations in AzureDevOpsReporter.ConsumeAsync(), which runs on every TestNodeUpdateMessage during Azure DevOps–enabled test runs.

Focus area: Code-Level Efficiency — eliminating unnecessary PropertyBag traversals and heap allocations for the common case (passing tests).

Approach

Before

EnsureEnabledConfigurationLoaded();
TestNodeStateProperty? nodeState = nodeUpdateMessage.TestNode.Properties.SingleOrDefault<TestNodeStateProperty>();
string testDisplayName = nodeUpdateMessage.TestNode.DisplayName;
string testName = GetTestName(nodeUpdateMessage.TestNode);  // ← always called

switch (nodeState)
{
    case FailedTestNodeStateProperty:   // testName used
    case ErrorTestNodeStateProperty:    // testName used
    case CancelledTestNodeStateProperty: // testName used
    case TimeoutTestNodeStateProperty:  // testName used
    // No case for Passed/Skipped/Discovered/InProgress — testName computed but discarded
}

GetTestName() used OfType<SerializableKeyValuePairStringProperty>() which:

  1. Walks the full PropertyBag linked list
  2. Allocates a SerializableKeyValuePairStringProperty[] for every call

After

Two targeted changes:

1. Defer GetTestName() to failure branches only:

switch (nodeState)
{
    case FailedTestNodeStateProperty failed:
        await WriteExceptionAsync(testDisplayName, GetTestName(nodeUpdateMessage.TestNode), ...);
        break;
    // same for Error, Cancelled, Timeout
}

Passing/skipped/in-progress tests never call GetTestName().

2. Replace OfType<T>().FirstOrDefault(predicate) with struct-enumerator short-circuit in GetTestName():

PropertyBag.PropertyBagEnumerator enumerator = testNode.Properties.GetStructEnumerator();
while (enumerator.MoveNext())
{
    if (enumerator.Current is SerializableKeyValuePairStringProperty kvp
        && kvp.Key == FullyQualifiedNamePropertyKey)
        return kvp.Value;
}
return testNode.DisplayName;

No SerializableKeyValuePairStringProperty[] allocated; exits as soon as the key is found.

Energy Efficiency Evidence

Proxy metric: Heap allocations + linked-list node visits per test result (fewer pointer dereferences = less DRAM traffic = less CPU energy).

Metric Before (passing test) After (passing test)
PropertyBag walks 2 (state + GetTestName) 1 (state only)
SerializableKeyValuePairStringProperty[] alloc 1 0
Metric Before (failing test) After (failing test)
PropertyBag walks 2 (state + GetTestName via OfType) 2 (state + GetTestName via struct enumerator, early exit)
Array allocs 1 0

For 1 000 passing tests in a CI run with --report-azdo enabled:

  • ~1 000 PropertyBag linked-list traversals eliminated
  • ~1 000 SerializableKeyValuePairStringProperty[] heap allocations eliminated

Green Software Foundation Context

Energy Proportionality: The reporter now does work proportional to the number of failures, not the total number of tests. Most CI runs have zero or few failures; the energy cost of the reporter now scales with the rare case rather than the common case.

Hardware Efficiency: Fewer heap allocations = less GC pressure = less memory bandwidth consumed per test result processed.

Trade-offs

  • GetTestName() is now called up to 4 times per failing test node (once per switch case) instead of once. In practice only one branch fires, so GetTestName() is still called at most once per update. No behavioural change.
  • The switch branches are slightly more verbose, but the pattern (not computing values before checking their precondition) is standard C# idiom.

Reproducibility

DOTNET_INSTALL_DIR=/usr/share/dotnet ./build.sh
artifacts/bin/Microsoft.Testing.Extensions.UnitTests/Debug/net8.0/Microsoft.Testing.Extensions.UnitTests
# Expected: 129 tests pass

Test Status

✅ Build: 0 errors, 0 warnings
Microsoft.Testing.Extensions.UnitTests: 129/129 passed

🤖 Automated content by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Efficiency Improver workflow. · 1.1K AIC · ⌖ 99.5 AIC · [◷]( · )

Add this agentic workflows to your repo

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/efficiency-improver.md@main

…c in AzureDevOpsReporter

ConsumeAsync() previously called GetTestName() eagerly for every
TestNodeUpdateMessage — including passing, skipped, discovered and
in-progress nodes — even though the result was only used inside the
four failure-state switch branches.

GetTestName() itself called OfType<SerializableKeyValuePairStringProperty>()
which (a) allocates a SerializableKeyValuePairStringProperty[] and
(b) walks the full PropertyBag linked list before the caller filters by
key with FirstOrDefault().

Two changes applied:
1. Move GetTestName() inside each failure-state branch so it is never
   invoked for passing/skipped/non-terminal test results.
2. Replace OfType<T>().FirstOrDefault(predicate) with a zero-allocation
   GetStructEnumerator() walk that short-circuits on the first matching key.

Before (per passing test in --report-azdo runs):
  - 1 PropertyBag walk (SingleOrDefault<TestNodeStateProperty>)
  - 1 PropertyBag walk (GetTestName → OfType<>)
  - 1 SerializableKeyValuePairStringProperty[] heap alloc

After (per passing test):
  - 1 PropertyBag walk (SingleOrDefault<TestNodeStateProperty>)
  - 0 extra walks, 0 extra allocs

After (per failing test):
  - 1 PropertyBag walk (SingleOrDefault<TestNodeStateProperty>)
  - 1 PropertyBag walk (GetTestName → GetStructEnumerator, early exit)
  - 0 heap allocs

For 1 000 passing tests: ~1 000 linked-list walks and ~1 000 array
allocations eliminated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 16, 2026 22:40
@Evangelink Evangelink added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 16, 2026
Copilot AI previously approved these changes Jun 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Ready to approve

The change is a targeted, low-risk perf improvement that preserves existing behavior while reducing allocations and unnecessary work in the hot path.

Note: this review does not count toward required approvals for merging.

Pull request overview

This PR improves runtime efficiency in the Azure DevOps reporter path (AzureDevOpsReporter.ConsumeAsync) by avoiding unnecessary property-bag work and allocations for the common case (non-failing test updates) within the Microsoft.Testing.Platform Azure DevOps reporting extension.

Changes:

  • Deferred GetTestName() computation so it only runs for failure/error/cancel/timeout test states.
  • Reworked GetTestName() to use PropertyBag.GetStructEnumerator() with an early-exit loop instead of PropertyBag.OfType<T>() (which materializes an array).
File summaries
File Description
src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs Defers test-name lookup to failure states and replaces OfType<T>() usage with a single-pass struct enumerator to reduce allocations.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 0

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.

@Evangelink Evangelink marked this pull request as ready for review June 17, 2026 05:54

@Evangelink Evangelink left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note

🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

Review Summary

Verdict: COMMENT — no blocking or major issues.

Findings

Dimension Result
Resource & IDisposable Management NIT (see inline)
All other 15 applicable dimensions ✅ LGTM

Detailed Notes

Performance claims are accurate.
The PR description correctly identifies that PropertyBag.OfType<T>() is a custom method that eagerly materializes a TProperty[] array — not LINQ's lazy OfType<T>(). The old code therefore allocated a SerializableKeyValuePairStringProperty[] on every call, including for passing tests. The new struct-enumerator path allocates nothing and short-circuits on the first match. Both the deferral optimisation and the enumerator swap are genuine wins.

Semantics are preserved.
PropertyBagEnumerator walks the linked list in the same order as the old OfType<T>() + FirstOrDefault chain. The behaviour of GetTestName() is identical to the previous implementation.

Threading — clean.
GetTestName() is a pure static function over immutable TestNode data; PropertyBagEnumerator is a local struct. All await calls retain ConfigureAwait(false). No concurrency issues.

Cross-TFM — clean.
GetStructEnumerator() is already used by a dozen callers across src/Platform/ on all supported TFMs. No new API surface is required.

Missing using on PropertyBagEnumerator (NIT — inline comment on line 442).
PropertyBagEnumerator implements IDisposable via IEnumerator<IProperty>, and the early-return path does not call Dispose(). The struct's Dispose() only nulls two stack-local reference fields, so there is zero real resource impact. Flagged as NIT for consistency with DiscoveredTestsJsonSerializer.cs, the sole existing caller that correctly uses using.

No new tests added.
GetTestName() is a private static method with unchanged semantics; existing coverage is inherited through ConsumeAsync() call paths. Acceptable for a refactor of this kind.

🤖 Automated content by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review (on PR ready) workflow. · 359.8 AIC · ⌖ 12.4 AIC ·

@Evangelink Evangelink enabled auto-merge (squash) June 17, 2026 07:12
…ble contract

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Evangelink Evangelink added the state/ready-to-merge PR is code-complete and CI-green; only awaiting required approval(s) to merge. label Jun 17, 2026
…ter-defer-getname-e4bfb2073b204687

# Conflicts:
#	src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs
Copilot AI review requested due to automatic review settings June 17, 2026 09:00
Copilot AI dismissed their stale review, a newer Copilot review was requested. June 17, 2026 09:03
Copilot AI previously approved these changes Jun 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Ready to approve

The changes are low-risk, preserve behavior, and improve the hot-path efficiency; only a minor comment wording nit was found.

Note: this review does not count toward required approvals for merging.

Copilot's findings
  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.

Comment thread src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/TestNodeIdentity.cs Outdated

@0101 0101 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.

Automated safety check passed: no dangerous changes detected (no secrets, exfiltration, destructive ops, or suspicious dependencies). Approving as requested. Note: this is a quick safety sanity check, not a full code review.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 17, 2026 09:57
Copilot AI dismissed their stale review, a newer Copilot review was requested. June 17, 2026 09:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Ready to approve

The changes are small, localized, and behavior-preserving, with only a minor comment-accuracy nit identified.

Note: this review does not count toward required approvals for merging.

Copilot's findings
  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Note

Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.

@Evangelink Evangelink merged commit c679659 into main Jun 17, 2026
27 checks passed
@Evangelink Evangelink deleted the efficiency/azdo-reporter-defer-getname-e4bfb2073b204687 branch June 17, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/performance Runtime / build performance / efficiency. state/ready-to-merge PR is code-complete and CI-green; only awaiting required approval(s) to merge. type/automation Created or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants