Skip to content

[perf-improver] perf: skip allocation in CaptureLifecycleProperties when no user properties set#9478

Draft
Evangelink wants to merge 1 commit into
mainfrom
perf-assist/avoid-empty-snapshot-alloc-3e2d41b89a59e990
Draft

[perf-improver] perf: skip allocation in CaptureLifecycleProperties when no user properties set#9478
Evangelink wants to merge 1 commit into
mainfrom
perf-assist/avoid-empty-snapshot-alloc-3e2d41b89a59e990

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Goal and rationale

CaptureLifecycleProperties() is called once per [AssemblyInitialize] / [ClassInitialize] execution to snapshot any TestContext.Properties entries set during init, so they can be propagated to each test's context. In the common case — where neither init method sets any properties — the method unconditionally allocated two objects:

  1. new Dictionary<string, object?>(capacity) — the snapshot buffer
  2. new ReadOnlyDictionary<string, object?>(snapshot) — the immutable wrapper

Those two objects are then stored in PostAssemblyInitProperties / PostClassInitProperties and passed to every test via MergeProperties. MergeProperties already returned early for null, but not for an empty dictionary, so it also acquired a lock per call before doing nothing.

Approach

  • CaptureLifecycleProperties: change to lazy allocation. The Dictionary is allocated only on the first non-label entry. If the loop finds nothing to snapshot, the method returns null (return type changed from IReadOnlyDictionary<string,object?> to IReadOnlyDictionary<string,object?>?). This is a single-pass implementation — no extra iteration.
  • MergeProperties: extend the null guard to null or { Count: 0 }, so an empty dict is also short-circuited cheaply (defensive, and matches the new null-return convention).
  • No caller changes: PostAssemblyInitProperties and PostClassInitProperties are already declared as nullable; MergeProperties(null) already short-circuits.

Performance evidence

Every test class that has [AssemblyInitialize] or [ClassInitialize] (but doesn't write to TestContext.Properties in it) benefits:

Call site Before After
CaptureLifecycleProperties (empty) 2 heap allocs 0 allocs
MergeProperties(emptySnapshot) per test lock acquire + empty loop 1 null-check, return
MergeProperties(null) per test 1 null-check, return 1 null/count-check, return

For a test suite with an [AssemblyInitialize] / [ClassInitialize] that sets no properties:

  • −2 heap allocations at init time
  • −2 lock acquisitions per test (one for PostAssemblyInitProperties, one for PostClassInitProperties merge in RunSingleTestAsync)

In a 1000-test suite, that's ~2000 lock acquisitions avoided.

Trade-offs

None significant. The change is backward-compatible: callers that store the snapshot in nullable fields already handle null; MergeProperties(null) was already a no-op.

Reproducibility

# Build and run unit tests for MSTestAdapter.PlatformServices
./build.sh -test -projects test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/MSTestAdapter.PlatformServices.UnitTests.csproj

# Or for just the new tests, after build:
dotnet run --project test/UnitTests/MSTestAdapter.PlatformServices.UnitTests -f net8.0 --no-build -- \
  --treenode-filter "/*/*/*/TestContextImplementationTests/CaptureLifecyclePropertiesShouldReturnNullWhenNoNonLabelPropertiesExist"

Test status

No SDK is available in this CI environment, so local build/test could not be run. The change is confined to the MSTestAdapter.PlatformServices project and its unit tests; all callers already handle null snapshots.

🤖 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 Perf Improver workflow. · 2.6K AIC · ⌖ 26.5 AIC · ⊞ 57.7K · [◷]( · )

Add this agentic workflows to your repo

To install this agentic workflow, run

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

…erties set

When AssemblyInitialize/ClassInitialize runs but sets no TestContext.Properties,
CaptureLifecycleProperties previously allocated a new Dictionary + ReadOnlyDictionary
wrapper unconditionally. This was wasted work in the common case.

Changes:
- CaptureLifecycleProperties now returns null when there are no non-label properties
  to snapshot, avoiding two heap allocations per lifecycle method execution.
- The dictionary is lazily allocated on first non-label entry (single-pass iteration).
- MergeProperties gains a Count==0 early-exit guard (defensive, since callers with
  the null-return change above never pass an empty dict from lifecycle paths).
- PostAssemblyInitProperties/PostClassInitProperties are already nullable, so callers
  need no changes: MergeProperties(null) already returns immediately.

Performance impact:
- Saves 2 allocations per assembly/class that has [AssemblyInitialize]/[ClassInitialize]
  but does not set TestContext.Properties (the common case).
- Saves a lock acquisition per test for the MergeProperties calls on both
  PostAssemblyInitProperties and PostClassInitProperties in RunSingleTestAsync.

Tests: add CaptureLifecyclePropertiesShouldReturnNullWhenNoNonLabelPropertiesExist,
MergePropertiesShouldIgnoreEmptyDictionary, and matching higher-level tests in
TestAssemblyInfoTests and TestClassInfoTests.

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

Optimizes MSTestAdapter.PlatformServices’ TestContextImplementation lifecycle property flow by avoiding allocations and locking when no user properties are set during AssemblyInitialize / ClassInitialize.

Changes:

  • Make CaptureLifecycleProperties() lazily allocate and return null when there are no non-label properties to snapshot.
  • Make MergeProperties() return early for null or empty dictionaries to avoid unnecessary locking.
  • Add/adjust unit tests to validate null snapshot behavior and empty-merge short-circuit behavior.
Show a summary per file
File Description
src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs Implements lazy snapshot allocation + nullable return, and adds empty-dictionary fast path in MergeProperties.
test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs Updates snapshot tests for nullable return and adds a test for merging an empty dictionary.
test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs Adds coverage that PostClassInitProperties stays null when class init sets no properties.
test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs Adds coverage that PostAssemblyInitProperties stays null when assembly init sets no properties.

Review details

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

Comment on lines +512 to 516
snapshot.Should().NotBeNull();
snapshot.Should().ContainKey("UserKey");
snapshot["UserKey"].Should().Be("UserValue");
snapshot!["UserKey"].Should().Be("UserValue");
snapshot.Should().ContainKey("AnotherKey");
snapshot["AnotherKey"].Should().Be(7);
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