Skip to content

Reuse per-thread MemoryStream/Utf8JsonWriter in EventSourceLogger.ToJson#131229

Draft
mangod9 wants to merge 1 commit into
dotnet:mainfrom
mangod9:perf/eventsource-logger-reuse-jsonwriter
Draft

Reuse per-thread MemoryStream/Utf8JsonWriter in EventSourceLogger.ToJson#131229
mangod9 wants to merge 1 commit into
dotnet:mainfrom
mangod9:perf/eventsource-logger-reuse-jsonwriter

Conversation

@mangod9

@mangod9 mangod9 commented Jul 22, 2026

Copy link
Copy Markdown
Member

Reduces per-log-event allocations in EventSourceLogger.ToJson, which runs on every logged event when the JsonMessage keyword is enabled (e.g. dotnet-trace collection).

Problem

ToJson allocated a new MemoryStream(), a new Utf8JsonWriter(), and their backing buffers on every call.

Change

Reuse a [ThreadStatic] MemoryStream + Utf8JsonWriter, resetting them per call via MemoryStream.SetLength(0) + Utf8JsonWriter.Reset(stream). The only remaining allocation is the returned JSON string. The approach uses only APIs available on all target frameworks (incl. netstandard2.0/net462).

Measurements (per-call allocation A/B, isolated harness)

props before B/call after B/call reduction speedup
0 824 32 96% 1.56x
2 912 120 87% 1.99x
6 960 168 82% 1.21x

The residual "after" bytes are only the returned result string.

Validation

  • Builds clean across all TFMs (net11/net10/netstandard2.0/net462), 0 warnings.
  • Microsoft.Extensions.Logging.EventSource unit tests pass (26/26). The JsonMessage-keyword tests parse and validate the emitted JSON, confirming byte-identical output.

Note

This pull request was generated with the assistance of GitHub Copilot.

ToJson allocated a new MemoryStream, a new Utf8JsonWriter, and their backing
buffers on every logged event when the JsonMessage keyword is enabled. Reuse a
[ThreadStatic] MemoryStream + Utf8JsonWriter, resetting them per call, so the
only remaining allocation is the returned JSON string.

Allocation A/B (per call): ~82-96% fewer bytes/call (824-960 B down to 32-168 B,
the residual being only the result string) and 1.2-2.0x faster. Output is
byte-identical; the JsonMessage-keyword unit tests parse and validate it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-extensions-logging
See info in area-owners.md if you want to be subscribed.

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 optimizes Microsoft.Extensions.Logging.EventSource’s JSON payload generation path by reusing per-thread serialization state in EventSourceLogger.ToJson, reducing per-event allocations when JSON logging is enabled.

Changes:

  • Introduces [ThreadStatic] caching for a MemoryStream and Utf8JsonWriter used by EventSourceLogger.ToJson.
  • Resets the cached instances per call via MemoryStream.SetLength(0) and Utf8JsonWriter.Reset(stream).
  • Simplifies UTF-8 decoding by directly producing the JSON string from the stream buffer (with a fallback to ToArray()).

Comment on lines +271 to +275
MemoryStream stream = t_jsonStream ??= new MemoryStream();
Utf8JsonWriter writer = t_jsonWriter ??= new Utf8JsonWriter(stream);

stream.SetLength(0);
writer.Reset(stream);

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

Two notes on the reworked ToJson. The thread-static reuse itself looks correct: it's fully synchronous, per-thread, and the buffer is never live across a reentrant call, so there's no corruption risk. The per-thread buffer retention is the one worth addressing.

: Encoding.UTF8.GetString(stream.ToArray());

return Encoding.UTF8.GetString(buffer.Array!, buffer.Offset, buffer.Count);
return result;

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.

The per-thread MemoryStream (and the writer's internal buffer) grows to the largest event ever serialized on this thread and is never shrunk, so a single large property value permanently inflates steady-state memory on long-lived thread-pool threads. ConsoleLogger handles the same pattern by capping its thread-static StringBuilder at 1024 (ConsoleLogger.cs). Suggest capping the stream the same way before returning:

Suggested change
return result;
if (stream.Capacity > 1024)
{
stream.Capacity = 1024;
}
return result;

{
buffer = new ArraySegment<byte>(stream.ToArray());
}
string result = stream.TryGetBuffer(out ArraySegment<byte> buffer)

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.

Minor: result just wraps the ternary and could be a direct return. Note that if you take the capacity-cap suggestion on the return line, keep the local (you need the string in hand before resetting Capacity); otherwise this can just be return stream.TryGetBuffer(...) ? ... : ...;.

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.

4 participants