[Experiment] Reduce Activity Links/Events first-node allocation (depends on #130610)#130617
[Experiment] Reduce Activity Links/Events first-node allocation (depends on #130610)#130617artl93 wants to merge 10 commits into
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
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
|
Tagging subscribers to this area: @steveisok, @dotnet/area-system-diagnostics-tracing |
There was a problem hiding this comment.
Pull request overview
This PR changes Activity’s internal storage for ActivityLink and ActivityEvent (and its already-stacked tag work) to co-locate the first node with the list container, aiming to avoid the extra heap allocation normally incurred by allocating a first DiagNode<T>.
Changes:
- Replace
Activity’s_links/_eventsstorage fromDiagLinkedList<T>to co-located first-node list containers (ActivityLinksLinkedList/ActivityEventsLinkedList). - Refactor
DiagLinkedList<T>.ToString()formatting to use shared helpers (ActivityLinkedListFormatting) reused by the new co-located list types. - Add new tests covering ToString equivalence and basic concurrent add stress for tags/links/events.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Diagnostics.DiagnosticSource/tests/ActivityTests.cs | Adds regression/stress tests for tag/link/event behavior and formatting. |
| src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/DiagLinkedList.cs | Makes DiagNode<T> inheritable and factors link/event formatting into shared helpers used by multiple list implementations. |
| src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Activity.cs | Introduces co-located first-node list containers for links/events and updates tag list to inherit from DiagNode<T>. |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 3
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 (2)
src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Activity.cs:1694
- TagsLinkedList can represent an empty state via
_last is null(e.g., whenSetTag(key, null)is the first operation). With the newDiagNode<>inheritance, the constructor always storesfirstValueintoValue, which retains the tag key/value even when the logical list is empty. This is a memory-retention regression vs the previous_first = nullrepresentation. Consider initializing the base node withdefaultand only assigningValuewhen the list is actually non-empty.
public TagsLinkedList(KeyValuePair<string, object?> firstValue, bool set = false) : base(firstValue)
{
if (!set || firstValue.Value is not null)
{
_last = this;
}
}
src/libraries/System.Diagnostics.DiagnosticSource/src/System/Diagnostics/Activity.cs:1790
- TagsLinkedList now uses the container object as the first node; when removing the last remaining tag (
next is null), the list becomes logically empty (_last = null) butValuestill holds the removed key/value, which keeps those objects alive as long as the Activity is alive. Clear the embedded node value when transitioning to empty to avoid retaining the last removed tag.
DiagNode<KeyValuePair<string, object?>>? next = Next;
if (next is null)
{
_last = null;
}
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
|
Note Benchmark code below was written with AI assistance (GitHub Copilot). @EgorBot -linux_amd -osx_arm64 using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);
[MemoryDiagnoser]
public class Bench
{
private ActivitySource _source = null!;
private ActivityListener _listener = null!;
private ActivityLink _link;
private ActivityEvent _event;
[GlobalSetup]
public void Setup()
{
_source = new ActivitySource("BenchSource");
_listener = new ActivityListener
{
ShouldListenTo = _ => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
};
ActivitySource.AddActivityListener(_listener);
_link = new ActivityLink(new ActivityContext(ActivityTraceId.CreateRandom(), ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded));
_event = new ActivityEvent("cache-miss");
}
[Benchmark(Baseline = true)]
public Activity? NoLinksNoEvents()
{
Activity? a = _source.StartActivity("op");
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneLinkAtCreation()
{
Activity? a = _source.StartActivity("op", ActivityKind.Internal, default(ActivityContext), links: new[] { _link });
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneEventAddedPostCreation()
{
Activity? a = _source.StartActivity("op");
a?.AddEvent(_event);
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneLinkAddedPostCreation()
{
Activity? a = _source.StartActivity("op");
a?.AddLink(_link);
a?.Stop();
return a;
}
[Benchmark]
public Activity? LinkPlusEvent()
{
Activity? a = _source.StartActivity("op", ActivityKind.Internal, default(ActivityContext), links: new[] { _link });
a?.AddEvent(_event);
a?.Stop();
return a;
}
} |
11a228f to
cf8f39d
Compare
|
Note Benchmark code below was written with AI assistance (GitHub Copilot). Re-posting after a rebase onto #130610's updated head (previous request was against the pre-rebase commit and never got a response). @EgorBot -linux_amd -osx_arm64 using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);
[MemoryDiagnoser]
public class Bench
{
private ActivitySource _source = null!;
private ActivityListener _listener = null!;
private ActivityLink _link;
private ActivityEvent _event;
[GlobalSetup]
public void Setup()
{
_source = new ActivitySource("BenchSource");
_listener = new ActivityListener
{
ShouldListenTo = _ => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
};
ActivitySource.AddActivityListener(_listener);
_link = new ActivityLink(new ActivityContext(ActivityTraceId.CreateRandom(), ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded));
_event = new ActivityEvent("cache-miss");
}
[Benchmark(Baseline = true)]
public Activity? NoLinksNoEvents()
{
Activity? a = _source.StartActivity("op");
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneLinkAtCreation()
{
Activity? a = _source.StartActivity("op", ActivityKind.Internal, default(ActivityContext), links: new[] { _link });
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneEventAddedPostCreation()
{
Activity? a = _source.StartActivity("op");
a?.AddEvent(_event);
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneLinkAddedPostCreation()
{
Activity? a = _source.StartActivity("op");
a?.AddLink(_link);
a?.Stop();
return a;
}
[Benchmark]
public Activity? LinkPlusEvent()
{
Activity? a = _source.StartActivity("op", ActivityKind.Internal, default(ActivityContext), links: new[] { _link });
a?.AddEvent(_event);
a?.Stop();
return a;
}
} |
Stack interaction updatePR #130610 is now the independent tag-node base PR and has been refreshed onto current Required merge order:
Do not merge this PR first: that would implicitly ship the tag change before its independent PR is complete. The link/event savings are separate and additive; they should not be treated as validation of #130610. Note This stack clarification was generated with GitHub Copilot. |
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
|
Note Benchmark code below was written with AI assistance (GitHub Copilot). Superseding the previous two requests, which used the undocumented @EgorBot -amd -osx_arm64 using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkSwitcher.FromAssembly(typeof(Bench).Assembly).Run(args);
[MemoryDiagnoser]
public class Bench
{
private ActivitySource _source = null!;
private ActivityListener _listener = null!;
private ActivityLink _link;
private ActivityEvent _event;
[GlobalSetup]
public void Setup()
{
_source = new ActivitySource("BenchSource");
_listener = new ActivityListener
{
ShouldListenTo = _ => true,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
};
ActivitySource.AddActivityListener(_listener);
_link = new ActivityLink(new ActivityContext(ActivityTraceId.CreateRandom(), ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded));
_event = new ActivityEvent("cache-miss");
}
[Benchmark(Baseline = true)]
public Activity? NoLinksNoEvents()
{
Activity? a = _source.StartActivity("op");
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneLinkAtCreation()
{
Activity? a = _source.StartActivity("op", ActivityKind.Internal, default(ActivityContext), links: new[] { _link });
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneEventAddedPostCreation()
{
Activity? a = _source.StartActivity("op");
a?.AddEvent(_event);
a?.Stop();
return a;
}
[Benchmark]
public Activity? OneLinkAddedPostCreation()
{
Activity? a = _source.StartActivity("op");
a?.AddLink(_link);
a?.Stop();
return a;
}
[Benchmark]
public Activity? LinkPlusEvent()
{
Activity? a = _source.StartActivity("op", ActivityKind.Internal, default(ActivityContext), links: new[] { _link });
a?.AddEvent(_event);
a?.Stop();
return a;
}
} |
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
cf8f39d to
b493856
Compare
Activity._links (DiagLinkedList<ActivityLink>) and Activity._events (DiagLinkedList<ActivityEvent>) allocated both a list container object and a separate first DiagNode<T> for the first link/event, exactly the same double-allocation pattern already fixed for Activity tags via TagsLinkedList in a prior change. Introduce ActivityLinksLinkedList and ActivityEventsLinkedList, which extend DiagNode<T> directly so the first node is co-located with the list container, mirroring TagsLinkedList. Unlike TagsLinkedList these new types are always constructed with an existing first value (Activity only lazily creates _links/_events once the first item exists), so no 'empty' sentinel state is needed. Extract the ActivityLink/ActivityEvent ToString formatting helpers out of DiagLinkedList<T> into a shared internal static ActivityLinkedListFormatting class so both DiagLinkedList<T> (still used for the empty-list singletons and by Metrics' Instrument/MeterListener) and the new co-located types produce identical output. Measured allocation deltas (GC.GetAllocatedBytesForCurrentThread, 20k iterations, macOS arm64, Release runtime/Debug test host): withLinksAtCreate: 544B -> 520B (-24B) addEventOne: 504B -> 480B (-24B) addLinkOne: 512B -> 488B (-24B) otelShaped (5 tags + 1 link at creation + 1 event): 888B -> 840B (-48B) Added regression tests covering ToString equivalence for links-at-creation vs AddLink, empty/single/multi-event ToString formatting, concurrent AddLink/AddEvent races, and Activity.Create's multi-link enumerator path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f160743-4487-407a-bcb1-e436d900088f
Adds 4 targeted differential/regression tests beyond the original 4: - Disabled/unsampled listener path neutrality (no allocation-relevant behavior change when Sample() declines recording) - Links/Events/Tags independence (interaction with Round-1 tags fix) - ActivityEvent tag data type coverage on the co-located first event node (null/string/int, via [Theory]) - ActivityLink tags survive co-location via both the constructor path and AddLink path Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f160743-4487-407a-bcb1-e436d900088f
- TestConcurrentAddLinkAndAddEventDoNotLoseEntries: wrap the Barrier in a using declaration so it is disposed. - Renamed/re-documented the sampling-declined test: it only proves an Activity is never created (and thus the co-located list types are never touched) for ActivitySamplingResult.None. The previous name and comment incorrectly implied Links/Events report empty for any non-AllDataAndRecorded result; PropagationData/AllData still create the Activity and pass links/tags/events through unchanged (that is non-binding guidance to listener authors, not enforced by the runtime), so no such claim should be made without a test that actually exercises it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7f160743-4487-407a-bcb1-e436d900088f
b493856 to
6ce0eed
Compare
b493856 to
6ce0eed
Compare
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:2862
- This test can deadlock/hang on runtimes where multithreading isn’t supported (e.g., single-threaded builds) because it uses Task.Run + Barrier synchronization. Similar multi-threaded tests in this file are guarded with ConditionalFact on PlatformDetection.IsMultithreadingSupported; this one should be too.
[Fact]
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
Note
Draft/experimental. Depends on #130610 (see below). Not ready for review.
What this does
Co-locates the first
ActivityLink/ActivityEventwith its list-container object onActivity, eliminating one heap allocation the first time a link or event is added to (or passed at creation of) anActivity. Same pattern as the tag co-location in #130610.Dependency on #130610
This branch is stacked on #130610 ("Reduce Activity tag storage allocations") and is not rebased/flattened onto plain
main:6e0474c0bca(includes its review fixes2a3ea726497b/ca8c39548fde, a merge of upstreammain, a test-portability tweak, a lock-free-read write-ordering fix inTagsLinkedList.Remove, and an invariant-culture test fix).2932592c7ac(Links/Events co-location),14ab396b529(added tests),6ce0eed1498(review fixes) — current head.Update: #130610 fixed all 4
TagsLinkedListissues the reviewer bot flagged on this PR's diff (prematurenewNodeallocation inAdd/Set, staleValueretention inRemoveand the empty-then-no-opSetTag(key, null)path) and merged current upstreammain. This branch has been rebased three times (git rebase --onto) to track #130610's tip as it evolved (09b7f6b27332→e8b8847af12→6e0474c0bca; the latest additions are a 1-line write-ordering fix inTagsLinkedList.Removefor lock-free readers, plus test-only tweaks — no impact on this PR's own Links/Events code), so the diff shown here is now just this PR's own Links/Events layer versus #130610's current state — re-verify viagh pr diffsince GitHub still computes the PR diff againstdotnet/runtime:main(GitHub can't retarget a PR's base to a fork-only branch), so it will keep including #130610's full commit history until #130610 itself merges intodotnet/runtime:main. All 6 reviewer-bot threads on this PR (4 pointing at #130610's code, 2 on this PR's own tests) are now resolved.Merge-order note: do not merge this PR before #130610 — the tag co-location and the link/event co-location are independent, additive savings, and this PR should not be treated as validation of #130610. Correct order: merge #130610 first, then retarget/flatten this PR onto resulting
mainand drop the duplicated tag-layer commits so it contains only link/event-specific changes.Full test suite re-verified after the rebase: 462/462 passed (
-c Debug -f net11.0).Design
Activity._links/_eventswere each aDiagLinkedList<T>, where every node (DiagNode<T>) is a separately heap-allocated wrapper around oneTplus aNextpointer. A list with exactly one entry — the common case: most OTel spans have 0-1 parent link and 0-1 event — allocates two objects (container + first node).ActivityLinksLinkedList/ActivityEventsLinkedListembed the first value as a field on the container itself, so the container is the first node; 2nd+ entries still allocate aDiagNode<T>as before. Enumeration order andToString()output are unchanged (verified by regression tests; formatting shared via extractedActivityLinkedListFormatting, used by both the new types and the legacyDiagLinkedList<T>.ToString()still used bySystem.Diagnostics.Metrics).The disabled/unsampled path (
ActivitySamplingResult.None) is untouched —Activity.Createisn't called at all in that case, so_links/_eventsstorage is never allocated either way.Canonical component-level allocation evidence
Deterministic per-operation allocation delta via
GC.GetAllocatedBytesForCurrentThread(), 20,000 iterations after 2,000-iteration warmup, forced GC before measurement. Same worktree/build across three exact SHAs (onlyActivity.cs/DiagLinkedList.csswapped + rebuilt),net11.0, Debug config.7aa830a0359)ca551806834)ff2ab8a0664)-24 B per non-empty links list and -24 B per non-empty events list, regardless of how many entries follow the first (only the first node is affected). Disabled/unsampled path is allocation-neutral (64 B/op, all three SHAs). Combined with Round-1's tag savings: -80 B/op on a typical OTel-shaped span vs. upstream.
Caveats: Debug config (Release testhost layout was incomplete in this worktree — allocation site count shouldn't differ by JIT tier, but a Release re-run is still pending); no BenchmarkDotNet run yet (standalone BDN console project fails outside full-repo context due to source generators — in-repo deterministic probe used instead, consistent with the prior round's approach); no E2E/Kestrel run (net11.0-local vs. net10.0-installed-runtime TFM mismatch, optional per scope); single-threaded only (concurrency correctness covered separately by xunit regression tests below, not by this allocation harness). Harness source is archived (available on request) but was not committed (temporary probe, not a permanent test).
BenchmarkDotNet run requested below via @EgorBot; will post results here once available.
Tests
8 regression tests added to
ActivityTests.cs:ToString()equivalence (links-at-creation vs.AddLink; empty/single/multi-event formatting), an 8-thread concurrentAddLink+AddEventstress test (no lost entries,Barrier-synchronized and disposed),Activity.Create's multi-link enumerator path, links/events/tags independence, tag-data-type round-tripping on the co-located first event/link ([Theory], null/string/int), and the no-Activity-created path forActivitySamplingResult.None.Full
System.Diagnostics.DiagnosticSource.Testssuite: 462/462 passed post-rebase (-c Debug -f net11.0; count is higher than the previously reported 457 because #130610 also added its own new tests, now included via the rebase).Alternatives / out of scope
A small fixed-capacity inline array (e.g. first 2-3 elements) was considered instead of just 1 — not pursued to keep this minimal and parallel to the already-reviewed tags pattern.
Activity.Baggage,ActivityContext/ActivityTraceId/ActivitySpanIdformatting/parsing, andDiagnosticListener.Writedispatch overhead were investigated during the broader profiling pass and found either already efficient or not clearly justified as a follow-on at this time — out of scope here.Note
This PR description and the linked evidence/analysis were prepared with AI assistance (GitHub Copilot). The author (@artl93) is coordinating this experiment and has reviewed the content.