Skip to content

[efficiency-improver] perf: add PropertyBag.FirstOrDefault(T)() to eliminate per-call array allocation#9488

Draft
Evangelink wants to merge 1 commit into
mainfrom
efficiency/propertybag-firstordefault-b36a19e9bbe579d0
Draft

[efficiency-improver] perf: add PropertyBag.FirstOrDefault(T)() to eliminate per-call array allocation#9488
Evangelink wants to merge 1 commit into
mainfrom
efficiency/propertybag-firstordefault-b36a19e9bbe579d0

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Goal

Add a zero-allocation FirstOrDefault<TProperty>() method to PropertyBag and update VideoRecorderSessionHandler to use it, eliminating two heap allocations per test state-change message.

Focus Area

Code-Level Efficiency — unnecessary object creation on a hot path.

Problem

PropertyBag exposes OfType<T>() which materialises results into a TProperty[] array. Callers that only want the first match were writing:

Properties.OfType<TestNodeStateProperty>().FirstOrDefault()

This allocates a TProperty[] for every call, even though only the first element is ever used. The array is immediately discarded. VideoRecorderSessionHandler has two such call sites, both on the per-test-update hot path.

Approach

Add PropertyBag.FirstOrDefault<TProperty>() modelled after the existing SingleOrDefault<TProperty>():

  1. Fast path: If TProperty is TestNodeStateProperty (or a subtype), check _testNodeStateProperty directly — O(1), zero allocation.
  2. Linked-list walk: For all other types, walk the internal Property? linked list with an early-exit on the first match — no array, no throw on duplicates.
public TProperty? FirstOrDefault<TProperty>()
    where TProperty : IProperty
{
    if (_testNodeStateProperty is TProperty testNodeStateProperty)
        return testNodeStateProperty;
    if (typeof(TestNodeStateProperty).IsAssignableFrom(typeof(TProperty)))
        return default;
    Property? current = _property;
    while (current is not null)
    {
        if (current.Current is TProperty match)
            return match;
        current = current.Next;
    }
    return default;
}

VideoRecorderSessionHandler now calls Properties.FirstOrDefault<T>() directly at both call sites.

Energy Efficiency Evidence

Proxy metric: Heap allocations eliminated per test update message.

Location Before After
VideoRecorderSessionHandler L128 TestNodeStateProperty[] allocated + LINQ enumeration Direct field read (_testNodeStateProperty), O(1), 0 alloc
VideoRecorderSessionHandler L480 TimingProperty[] allocated + LINQ enumeration Linked-list walk, early-exit, 0 alloc

Eliminating heap allocations directly reduces GC pressure. Less GC means fewer stop-the-world pauses and fewer CPU cycles spent on collection — translating to lower energy per functional unit (test run).

Limitation: We do not have direct energy measurements. The reasoning is:

  • Fewer heap objects → shorter / less frequent GC collections → fewer CPU cycles on GC → reduced energy.
  • This is a well-established proxy relationship.

Green Software Foundation Context

Hardware Efficiency: Making better use of the hardware by avoiding unnecessary memory round-trips. Every array the GC does not have to scan, trace, and collect is CPU time reclaimed for useful work, reducing the energy per test execution.

Trade-offs

None: the new method is semantically equivalent to the previous pattern for the single-match case (which is the only realistic scenario given PropertyBag's enforcement of uniqueness for TestNodeStateProperty). The only behavioural difference is that this method does not throw when multiple properties of the same type are present — which is exactly the defensive behaviour the code comments already called for.

Reproducibility

# Measure allocations with dotnet-trace or BenchmarkDotNet (no perf benchmarks
# currently exist for PropertyBag):
dotnet trace collect --providers Microsoft-DotNETRuntime:0x1:5 -- \
  dotnet run --project test/...

Test Status

CI will validate. Changes are self-contained: new public API + two call-site updates.

🤖 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. · 3K AIC · ⌖ 39 AIC · ⊞ 58.8K · [◷]( · )

Add this agentic workflows to your repo

To install this agentic workflow, run

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

Add a zero-allocation FirstOrDefault<TProperty>() method to PropertyBag
that walks the internal linked list directly, returning the first match
without materialising a TProperty[] array.

Previously, callers used:
  Properties.OfType<T>().FirstOrDefault()

PropertyBag.OfType<T>() allocates a TProperty[] even for the common
single-element case, and the subsequent LINQ .FirstOrDefault() iterates
it. This results in a heap allocation per call that is immediately
discarded.

The new method:
- Returns _testNodeStateProperty directly (O(1), zero alloc) when T
  is TestNodeStateProperty or a subtype
- Walks the linked list with an early-exit on first match for all
  other types — no intermediate array, no throw on duplicates

VideoRecorderSessionHandler had two call sites on the hot path
(once per test state-change message):
  update.TestNode.Properties.OfType<TestNodeStateProperty>().FirstOrDefault()
  update.TestNode.Properties.OfType<TimingProperty>().FirstOrDefault()
Both are updated to use Properties.FirstOrDefault<T>() directly.

Proxy metric: heap allocations eliminated per test update message.
GSF principle: Hardware Efficiency — less GC pressure means the CPU
spends fewer cycles on collection, reducing energy per functional unit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 28, 2026 22:17
@Evangelink Evangelink added area/performance Runtime / build performance / efficiency. type/automation Created or maintained by an agentic workflow. labels Jun 28, 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.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR introduces a new PropertyBag.FirstOrDefault<TProperty>() API to retrieve the first matching property without throwing when duplicates exist, and updates the video recorder extension to use it instead of LINQ-based enumeration.

Changes:

  • Added PropertyBag.FirstOrDefault<TProperty>() as a public API (tracked in PublicAPI.Unshipped files).
  • Implemented an allocation-free linked-list walk for first-match lookup in PropertyBag.
  • Updated VideoRecorderSessionHandler to use the new method for TestNodeStateProperty and TimingProperty retrieval.
Show a summary per file
File Description
src/Platform/Microsoft.Testing.Platform/PublicAPI/net/PublicAPI.Unshipped.txt Tracks newly added PropertyBag.FirstOrDefault<TProperty>() API for net target.
src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt Tracks newly added PropertyBag.FirstOrDefault<TProperty>() API for general PublicAPI.
src/Platform/Microsoft.Testing.Platform/Messages/PropertyBag.cs Adds FirstOrDefault<TProperty>() implementation with fast-path and linked-list traversal.
src/Platform/Microsoft.Testing.Extensions.VideoRecorder/VideoRecorderSessionHandler.cs Replaces LINQ OfType().FirstOrDefault() with PropertyBag.FirstOrDefault<T>().

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment on lines +216 to +224
/// <summary>
/// Returns the first property of the <typeparamref name="TProperty"/> type, or <see langword="null"/> if none is found.
/// Unlike <see cref="SingleOrDefault{TProperty}"/>, this method does not throw when multiple properties of the
/// same type are present — it simply returns the first one encountered.
/// </summary>
/// <typeparam name="TProperty">The type of the property.</typeparam>
/// <returns>The first property of the given type, or <see langword="null"/> if none is found.</returns>
public TProperty? FirstOrDefault<TProperty>()
where TProperty : IProperty
Comment on lines +231 to +235
// We don't want to walk the linked list if we know that we're looking for a TestNodeStateProperty.
if (typeof(TestNodeStateProperty).IsAssignableFrom(typeof(TProperty)))
{
return default;
}
@Evangelink

Copy link
Copy Markdown
Member Author

🔍 Build Failure Analysis

Summary — The build fails with RS0025 because PropertyBag.FirstOrDefault<TProperty>() was registered in two Public API tracking files for the same project, causing the Roslyn PublicApiAnalyzers to see the symbol declared twice.

Root cause: Duplicate entry in PublicAPI.Unshipped.txt (RS0025)

The PR added the new public API declaration to both:

File Role
PublicAPI/PublicAPI.Unshipped.txt:3 Base file — covers all target frameworks
PublicAPI/net/PublicAPI.Unshipped.txt:2 TFM-specific file — covers .NET only

When MSBuild compiles the net target framework of Microsoft.Testing.Platform, the analyzer reads both files and encounters PropertyBag.FirstOrDefault<TProperty>() -> TProperty? in each — triggering RS0025 ("symbol appears more than once in the public API files"). The error fires twice because the project is built for multiple target frameworks and both builds include the .net TFM-specific file alongside the base file.

Affected errors (2)

Code File Line Message
RS0025 PublicAPI/PublicAPI.Unshipped.txt 3 Symbol PropertyBag.FirstOrDefault<TProperty>() -> TProperty? appears more than once
RS0025 PublicAPI/PublicAPI.Unshipped.txt 3 (same — second TFM build)

Proposed fix

FirstOrDefault<TProperty>() contains no #if NET-guarded code in PropertyBag.cs, so it is available on all target frameworks. It belongs only in the base PublicAPI/PublicAPI.Unshipped.txt. Remove the duplicate line from the TFM-specific file:

# src/Platform/Microsoft.Testing.Platform/PublicAPI/net/PublicAPI.Unshipped.txt
  #nullable enable
- Microsoft.Testing.Platform.Extensions.Messages.PropertyBag.FirstOrDefault<TProperty>() -> TProperty?

The base file already has the correct entry at line 3 — no change needed there.


Build overview
Build: FAILED | Duration: 177.2s | MSBuild: 18.7.0-preview
Projects: 48 | Errors: 3 | Warnings: 0

Failed projects:
  ✗ Build.proj
  ✗ NonWindowsTests.slnf
  ✗ Microsoft.Testing.Extensions.CrashDump.csproj
  ✗ Microsoft.Testing.Extensions.TrxReport.Abstractions.csproj
  ✗ Microsoft.Testing.Platform.csproj  ← root cause here
All MSBuild errors (2)
Code Project File:Line Message
RS0025 Microsoft.Testing.Platform PublicAPI/PublicAPI.Unshipped.txt:3 The symbol Microsoft.Testing.Platform.Extensions.Messages.PropertyBag.FirstOrDefault<TProperty>() -> TProperty? appears more than once in the public API files
RS0025 Microsoft.Testing.Platform PublicAPI/PublicAPI.Unshipped.txt:3 (same, second TFM evaluation)

🤖 Generated by the Build Failure Analysis workflow using [binlog-mcp]((dev.azure.com/redacted) · commit 119b52666c9723ac2c09d679b2a2dc1a2dc31998

🤖 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 Build Failure Analysis workflow. · 179.5 AIC · ⌖ 14 AIC · ⊞ 47K · [◷]( · )

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

🤖 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 Build Failure Analysis workflow. · 179.5 AIC · ⌖ 14 AIC · ⊞ 47K ·

@@ -1 +1,2 @@
#nullable enable
Microsoft.Testing.Platform.Extensions.Messages.PropertyBag.FirstOrDefault<TProperty>() -> TProperty?

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.

🔧 RS0025 — This entry duplicates the one already present in the base PublicAPI/PublicAPI.Unshipped.txt. The net/ subdirectory file is for APIs that are .NET-TFM-only; since FirstOrDefault<TProperty>() has no #if NET guard and is available on all target frameworks, it should live only in the base file.

Remove this line to fix the duplicate-symbol build error:

Suggested change
Microsoft.Testing.Platform.Extensions.Messages.PropertyBag.FirstOrDefault<TProperty>() -> TProperty?

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. type/automation Created or maintained by an agentic workflow.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants