Repository navigation
Conversation
Co-locate the first Activity tag with its list container, avoiding one allocation for every tagged Activity while preserving concurrent tag addition semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
|
Tagging subscribers to this area: @steveisok, @dotnet/area-system-diagnostics-tracing |
There was a problem hiding this comment.
Pull request overview
This PR experiments with reducing allocations in System.Diagnostics.Activity tag storage by co-locating the first tag node with the tag-list container and updating tag formatting logic.
Changes:
- Refactors
Activity.TagsLinkedListto inherit fromDiagNode<KeyValuePair<string, object?>>, eliminating a separate first-node allocation. - Updates
TagsLinkedList.ToString()to useValueStringBuilderinstead of a cachedStringBuilder. - Adds a new test asserting concurrent
AddTagcalls preserve all tag values.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivityTests.cs | Adds a new concurrent-add tags test. |
| src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DiagLinkedList.cs | Makes DiagNode<T> non-sealed to enable deriving the tag list from it. |
| src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Activity.cs | Implements the co-located-first-node tag list and updates tag-list string formatting. |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 3
Avoid retaining removed or ignored tag keys and values in the co-located tag node, and cover formatting and empty-list invariants. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
Avoid discarded nodes when repopulating empty tag storage, dispose enumerators, and make reflection-based invariant tests fail clearly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
Refresh CI against current main without altering the Activity optimization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
There was a problem hiding this comment.
Copilot's findings
Comments suppressed due to low confidence (1)
src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivityTests.cs:242
- This test uses Parallel.For and assumes multithreading support, but it is a plain [Fact]. The file already guards other multithreaded tests with PlatformDetection.IsMultithreadingSupported; without the guard, this can fail on threadless targets (e.g., some WASM/mobile configurations).
[Fact]
public void ConcurrentTagAddsPreserveAllValues()
{
const int Count = 1_000;
Activity activity = new Activity("activity");
Parallel.For(0, Count, i => activity.AddTag(i.ToString(), i));
- Files reviewed: 3/3 changed files
- Comments generated: 1
Skip concurrent tag coverage where multithreading is unavailable and align tag element nullability with the public enumeration contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
Mark the co-located tag list empty before clearing its stored value so lock-free readers do not observe a default tag during removal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
Keep concurrent tag keys deterministic across test cultures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ffc63197-9684-4dd6-a055-b605b57cbfd2
|
@EgorBot -amd -osx_arm64 using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
[MemoryDiagnoser]
public class ActivityTagBenchmarks
{
private ActivitySource _source = null!;
private ActivityListener _listener = null!;
private ActivityContext _parent;
private KeyValuePair<string, object?>[] _stringTags = null!;
private KeyValuePair<string, object?>[] _objectTags = null!;
[Params(0, 1, 5, 20)]
public int TagCount { get; set; }
[Params(false, true)]
public bool ObjectValues { get; set; }
[GlobalSetup]
public void Setup()
{
_source = new ActivitySource("EgorBot.Activity.Tags");
_parent = ActivityContext.Parse(
"00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
"vendor=value");
_stringTags = Enumerable.Range(0, 20)
.Select(static i => new KeyValuePair<string, object?>($"tag.{i}", $"value.{i}"))
.ToArray();
_objectTags = Enumerable.Range(0, 20)
.Select(static i => new KeyValuePair<string, object?>($"tag.{i}", new TagValue(i)))
.ToArray();
_listener = new ActivityListener
{
ShouldListenTo = static source => source.Name == "EgorBot.Activity.Tags",
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.AllDataAndRecorded,
};
ActivitySource.AddActivityListener(_listener);
}
[GlobalCleanup]
public void Cleanup()
{
_listener.Dispose();
_source.Dispose();
}
[Benchmark]
public Activity? StartStopWithTags()
{
Activity? activity = _source.StartActivity("request", ActivityKind.Server, _parent);
KeyValuePair<string, object?>[] tags = ObjectValues ? _objectTags : _stringTags;
for (int i = 0; i < TagCount; i++)
{
KeyValuePair<string, object?> tag = tags[i];
activity!.SetTag(tag.Key, tag.Value);
}
activity?.Stop();
return activity;
}
private sealed class TagValue(int value)
{
public override string ToString() => $"object-{value}";
}
}
[MemoryDiagnoser]
public class InactiveActivityBenchmarks
{
private ActivitySource _source = null!;
private ActivityListener _listener = null!;
[Params(false, true)]
public bool ListenerPresent { get; set; }
[GlobalSetup]
public void Setup()
{
_source = new ActivitySource(
ListenerPresent
? "EgorBot.Activity.Unsampled"
: "EgorBot.Activity.Disabled");
_listener = new ActivityListener
{
ShouldListenTo = static source => source.Name == "EgorBot.Activity.Unsampled",
Sample = static (ref ActivityCreationOptions<ActivityContext> _) =>
ActivitySamplingResult.None,
};
ActivitySource.AddActivityListener(_listener);
}
[GlobalCleanup]
public void Cleanup()
{
_listener.Dispose();
_source.Dispose();
}
[Benchmark]
public Activity? StartActivity() => _source.StartActivity("request");
}Note This benchmark request was generated with GitHub Copilot. |
|
This remains work in progress and draft. The exact-head implementation, local BDN, focused/full DiagnosticSource tests, and review-thread pass are complete. Build Analysis is green and classifies the two raw CI failures as known issues (#105124 and #130948); the raw failed jobs still need a rerun. EgorBot acknowledged the sole current-head request but has not posted results. I will not mark the PR ready while those external gates remain. Note This status update was prepared with GitHub Copilot assistance and reviewed by the author. |
Motivation
A sampled
Activitycurrently allocates both aTagsLinkedListcontainer and a separate firstDiagNode<KeyValuePair<string, object?>>when its first tag is recorded. OpenTelemetry-style server/client spans commonly carry semantic-convention tags, so this redundant object is paid by nearly every exported span.Change
Make
TagsLinkedListinheritDiagNode<KeyValuePair<string, object?>>, so the list container is also its first node. Later tags continue to use the existing linked-node representation.The change preserves tag order, duplicate handling, lookup, mutation, enumeration, string/object/null formatting, listener behavior, and the public API. Empty storage clears ignored or removed keys/values. Repopulating an empty list reuses the co-located node without allocating and discarding a node, enumerable inputs dispose their enumerator, and empty state is published before clearing the stored value for lock-free readers.
BDN + EgorBot
Exact paired local BenchmarkDotNet run:
c1b09d933f7d720f7ae50f80b4ec6980bb93d96ff4564b1b85c6328e94c18a240af7e976d733bb906e0474c0bca(invariant-culture test-only follow-up; product source is identical to the benchmarked revision)0.16.0-preview.1, .NET 11, Apple M5 Pro/macOS arm64No-listener and listener-present-but-unsampled controls allocate 0 B in both revisions and are effectively unchanged in the final run. The deterministic acceptance result is one fewer 32-byte object per tagged Activity.
Exactly one current-head cross-platform EgorBot request (
-amd -osx_arm64) is posted in this comment. EgorBot acknowledged it; results are pending.Local evidence provenance:
fbcdac15a0c5252cf75fc7fdd5f282b75e1ce0da74808678fb933286d01d6e402dc17805a732615a306e40a5bd5821c8a3d3af4adaec218fb197550d55fd168a5cb691bdf1c61bbeda3c0bb33811512200bdbb904f7b002d8fd5eca8e1b363d0Validation
./dotnet.sh build src/libraries/System.Diagnostics.DiagnosticSource/src/System.Diagnostics.DiagnosticSource.csproj --no-restore ./dotnet.sh build /t:test src/libraries/System.Diagnostics.DiagnosticSource/tests/System.Diagnostics.DiagnosticSource.Tests.csproj --no-restore /p:XUnitOptions="-class System.Diagnostics.Tests.ActivityTests" ./dotnet.sh build /t:test src/libraries/System.Diagnostics.DiagnosticSource/tests/System.Diagnostics.DiagnosticSource.Tests.csproj --no-restoreActivityTests: 102 passedRisks
_last is nullas the empty-list invariant; dedicated tests pin empty/removal/repopulation behavior and cleared references.Interlocked.CompareExchange.mainand drop its copied/older tag commits; it must not be merged first. Its link/event allocation savings are separate and additive, not evidence for this tag change. The reciprocal stack note is posted on [Experiment] Reduce Activity Links/Events first-node allocation (depends on #130610) #130617.TransactionManagerTest.DefaultTimeout_MaxTimeout_Set_Get(#105124) and the x86 Zstd OOM (#130948). Build Analysis succeeded and matched both KBEs. The raw runtime check remains red pending a failed-job rerun; EgorBot results are also pending. This PR remains draft.AI disclosure
Note
This PR implementation, benchmarks, and description were prepared with GitHub Copilot assistance and reviewed by the author.