Skip to content

[efficiency-improver] perf: eliminate Duration.ToString() and Guid.ToString() allocations in IPC serializers#16216

Draft
nohwnd wants to merge 1 commit into
mainfrom
efficiency/eliminate-duration-tostring-in-serializers-1deb46d88fccf54d
Draft

[efficiency-improver] perf: eliminate Duration.ToString() and Guid.ToString() allocations in IPC serializers#16216
nohwnd wants to merge 1 commit into
mainfrom
efficiency/eliminate-duration-tostring-in-serializers-1deb46d88fccf54d

Conversation

@nohwnd

@nohwnd nohwnd commented Jul 5, 2026

Copy link
Copy Markdown
Member

Goal and Rationale

Eliminate two categories of heap allocation on the IPC serialization hot path — called for every TestResult and TestCase crossing the vstest.console ↔ testhost process boundary.

Focus area: Network & I/O Efficiency (IPC serialization write path)

Problem

1. Duration.ToString()TestResultConverterV2 and TestResultConverter

// Before — allocates a heap string per TestResult
writer.WriteString("Duration", value.Duration.ToString());
writer.WriteStringValue(value.Duration.ToString());

TimeSpan.ToString() always allocates a heap string (typically 7–26 bytes UTF-16 encoded). This string is immediately consumed by WriteString/WriteStringValue and then eligible for GC. It serves no purpose beyond bridging the absence of a WriteString(string, TimeSpan) overload.

2. Guid.ToString()TestCaseConverter (v1 protocol)

// Before — allocates a heap string per TestCase
writer.WriteStringValue(value.Id.ToString());

Guid.ToString() allocates a 72–88 byte heap string (36-char UUID). Utf8JsonWriter already has a WriteStringValue(Guid) overload that writes the UUID directly from the struct, without any heap allocation. The V2 converter (TestCaseConverterV2) already uses this overload (line 79); the V1 converter was overlooked.

Approach

Duration fix (both converters): allocate a 32-char stack buffer, format in-place with TryFormat, write directly via WriteString(string, ReadOnlySpan<char>) / WriteStringValue(ReadOnlySpan<char>):

Span<char> durationBuf = stackalloc char[32];
value.Duration.TryFormat(durationBuf, out int durationChars, default, CultureInfo.InvariantCulture);
writer.WriteString("Duration", durationBuf[..durationChars]);

Guid fix (TestCaseConverter): use the existing zero-allocation overload:

writer.WriteStringValue(value.Id);   // Guid overload — no ToString(), no heap alloc

Energy Efficiency Evidence

Proxy metric: Memory allocation (GC pressure on the IPC write thread)

Site Before After
TestResultConverterV2.Write — Duration 1 heap string (~7–26 chars) per result 0 heap allocs — stack buffer
TestResultConverter.Write — Duration 1 heap string (~7–26 chars) per result 0 heap allocs — stack buffer
TestCaseConverter.Write — Guid 1 heap string (36 chars / ~72 bytes) per case 0 heap allocs — Guid overload

For a run with 10,000 tests:

  • Duration: eliminates ~10,000 short-lived Gen0 strings on the write thread across both converter paths
  • Guid (V1 protocol): eliminates ~10,000 36-char heap strings per run when using the V1 wire protocol

Green Software Foundation context:

  • Hardware Efficiency: fewer Gen0 objects → fewer minor GC collections → more CPU cycles on useful serialization work
  • SCI (Software Carbon Intensity): reduced CPU energy per dotnet test invocation, proportional to test count and run frequency

Trade-offs

None. TimeSpan.TryFormat produces byte-for-byte identical output to TimeSpan.ToString() (same "G" general format). WriteStringValue(Guid) produces byte-for-byte identical JSON UUID string output. All existing round-trip tests pass.

The stackalloc char[32] is safe: TimeSpan.MaxValue formats to 26 chars (-10675199.02:48:05.4775807); 32 gives ample headroom.

Reproducibility

# Verify no Duration.ToString() remains on the write path:
grep -n "Duration\.ToString\|\.ToString()" \
  src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResult*.cs \
  src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCase*.cs

# Run the full serializer test suite:
.dotnet/dotnet run --project test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/ \
  -c Release -f net11.0

Test Status

  • ✅ Build: 0 errors (4 pre-existing IL-trimming warnings, unrelated)
  • Microsoft.TestPlatform.CommunicationUtilities.UnitTests: 632/632 passed, 0 failures

Generated by Efficiency Improver · 1.3K AIC · ⌖ 28.2 AIC · ⊞ 45.6K ·

…n IPC serializers

Replace heap-allocating ToString() calls with zero-allocation alternatives on the
IPC serialization hot path (called for every TestResult and TestCase crossing the
process boundary):

- TestResultConverterV2.Write / TestResultConverter.Write: replace
  value.Duration.ToString() with TimeSpan.TryFormat + stackalloc char[32] buffer,
  then write via WriteString/WriteStringValue(ReadOnlySpan<char>). Eliminates one
  heap string allocation (~26-50 bytes) per TestResult on the IPC write path.

- TestCaseConverter.Write: replace writer.WriteStringValue(value.Id.ToString())
  with writer.WriteStringValue(value.Id) (the Guid overload). Eliminates one
  heap string allocation (~72-88 bytes) per TestCase on the IPC write path. The
  V2 converter already used this pattern; now V1 matches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 5, 2026 17:12

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

This PR reduces allocations on the Microsoft.TestPlatform.CommunicationUtilities IPC JSON serialization write path by avoiding per-item TimeSpan.ToString() and Guid.ToString() string allocations when serializing TestResult.Duration and TestCase.Id across the vstest.console ↔ testhost boundary.

Changes:

  • Replace TimeSpan.ToString() for TestResult.Duration with TimeSpan.TryFormat into a stackalloc buffer, written via span-based Utf8JsonWriter APIs (V1 and V2 converters).
  • Replace Guid.ToString() for TestCase.Id with Utf8JsonWriter.WriteStringValue(Guid) (V1 converter), matching the already-optimized V2 path.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResultConverterV2.cs Eliminates Duration.ToString() allocation by formatting into a stack buffer and writing via span overload.
src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResultConverter.cs Same Duration stack-buffer formatting change for the v1 protocol serializer.
src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverter.cs Eliminates Guid.ToString() allocation by using Utf8JsonWriter’s Guid overload for v1 protocol serialization.

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

Review: perf: eliminate Duration.ToString() and Guid.ToString() allocations in IPC serializers

Dimensions activated: IPC Transport & Protocol Stability · Backward Compatibility & Rollback Safety · Defensive Coding at Boundaries


Wire Compatibility ✅

Both changes produce byte-for-byte identical JSON output — verified against the IPC contract:

Change Before After Identical?
TestCaseConverter — Guid Guid.ToString() → "D" format, lowercase WriteStringValue(Guid) → "D" format internally ✅ Yes
TestResultConverter — Duration TimeSpan.ToString() → "c" (constant) format TryFormat(..., default, ...) → "c" format (empty format sentinel defaults to "c") ✅ Yes
TestResultConverterV2 — Duration same same ✅ Yes

The deserialization paths (TimeSpan.Parse(..., CultureInfo.InvariantCulture) and GuidPolyfill.Parse) are unchanged and accept both the old and new serialized forms without modification.

Cross-TFM Compatibility ✅

All three files are entirely guarded by #if NETCOREAPP. Span<T>, stackalloc char[N], TimeSpan.TryFormat, and Utf8JsonWriter.WriteStringValue(ReadOnlySpan<char>) are all available on every NETCOREAPP target. No net462/netstandard2.0 exposure.

Finding: TryFormat return value ignored ⚠️

See inline comments on TestResultConverter.cs:165 and TestResultConverterV2.cs:129.

The 32-char buffer is provably sufficient today — TimeSpan.MinValue serializes to 27 chars, leaving 5 chars of headroom. But TryFormat returning false silently writes an empty Duration string ("") into the IPC stream, causing TimeSpan.Parse to throw FormatException on the receiving side. Adding Debug.Assert would make the invariant explicit and surface any future regression immediately in debug builds. Not a correctness bug with the current code; flagged as a hardening suggestion.

PR Description Alignment ✅

The title and description accurately describe the changes. The claimed format equivalence ("byte-for-byte identical"), buffer size analysis (26 chars for MaxValue, 32 headroom), and protocol version scope (V1 + V2 converters) all match the diff exactly. The note that TestCaseConverterV2 already used the Guid overload is correct.


🧠 Reviewed by expert-reviewer workflow · Dimensions: IPC Transport & Protocol Stability, Backward Compatibility & Rollback Safety, Defensive Coding

🧠 Reviewed by Expert Code Reviewer 🧠

writer.WriteString("ComputerName", value.ComputerName);
writer.WriteString("Duration", value.Duration.ToString());
Span<char> durationBuf = stackalloc char[32];
value.Duration.TryFormat(durationBuf, out int durationChars, default, CultureInfo.InvariantCulture);

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.

[Defensive Coding / IPC Transport] Same TryFormat return-value concern as in TestResultConverter.cs: if the call returns false, durationChars is 0, the Duration field is written as "" into the IPC stream, and TimeSpan.Parse on the receiving side throws a FormatException.

Same mitigation applies — capture the return value and add a Debug.Assert:

Span<char> durationBuf = stackalloc char[32];
bool formatted = value.Duration.TryFormat(durationBuf, out int durationChars, default, CultureInfo.InvariantCulture);
Debug.Assert(formatted, "TimeSpan.TryFormat failed — buffer too small");
writer.WriteString("Duration", durationBuf[..durationChars]);

WriteProperty(writer, TestResultProperties.Duration, options);
writer.WriteStringValue(value.Duration.ToString());
Span<char> durationBuf = stackalloc char[32];
value.Duration.TryFormat(durationBuf, out int durationChars, default, CultureInfo.InvariantCulture);

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.

[Defensive Coding / IPC Transport] The return value of TryFormat is silently discarded.

value.Duration.TryFormat(durationBuf, out int durationChars, default, CultureInfo.InvariantCulture);

If TryFormat returns false (buffer too small), durationChars is set to 0 and durationBuf[..0] is an empty span. WriteStringValue(ReadOnlySpan<char>.Empty) then writes "" into the IPC wire stream — and the receiving side's TimeSpan.Parse("", CultureInfo.InvariantCulture) throws a FormatException, resulting in a silent IPC deserialization failure that's extremely hard to diagnose.

The 32-char buffer is provably sufficient today (TimeSpan.MinValue = "-10675199.02:48:05.4775808" = 27 chars), but a future format-specifier change or a code change that reuses this pattern with a different type could trigger the failure path silently.

Suggestion — make the intent explicit and crash-fast on a broken invariant:

Span<char> durationBuf = stackalloc char[32];
bool formatted = value.Duration.TryFormat(durationBuf, out int durationChars, default, CultureInfo.InvariantCulture);
Debug.Assert(formatted, "TimeSpan.TryFormat failed — buffer too small");
writer.WriteStringValue(durationBuf[..durationChars]);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants