Skip to content

WIP: DO NOT REVIEW Reduce Activity tag storage allocations#130610

Draft
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-activity-tracing-perf
Draft

WIP: DO NOT REVIEW Reduce Activity tag storage allocations#130610
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-activity-tracing-perf

Conversation

@artl93

@artl93 artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member

I am running an experiment - full analysis to follow. Contact @artl93

Important

This remains a draft / work in progress. Please do not review it yet.

Summary

Activity currently allocates two objects when its first tag is added:

  1. a TagsLinkedList container, and
  2. a separate DiagNode<KeyValuePair<string, object?>> for the first value.

This experiment makes TagsLinkedList itself the first node. Later tags still use the same DiagNode chain, so tag order, duplicate handling, lookup, mutation, and public APIs are unchanged. The result is exactly one fewer object for every tagged Activity.

This targets OpenTelemetry-style sampled spans, where server/client Activities commonly carry semantic-convention tags. It does not affect histogram aggregation.

Canonical local result

Exact revisions:

  • Base: 7aa830a03599a8255c2c4abf2947afc5b346cc6f
  • Final: 2a3ea726497b7d04f887a3fdb8afa4c3359bfa1d

Same-process paired BenchmarkDotNet 0.16.0-preview.1, exact W3C parent, .NET 11, Apple M5 Pro/macOS arm64, 3 launches / 8 warmups / 12 measured iterations:

Tags Base allocation Final allocation Delta Base mean Final mean
0 328 B 328 B 0 B 122.9 ns 128.7 ns
1 408 B 376 B -32 B 144.0 ns 131.9 ns
5 568 B 536 B -32 B 196.2 ns 196.0 ns
20 1168 B 1136 B -32 B 625.1 ns 608.9 ns

Separate full runs cover pre-created string and object values and produce identical allocation counts. The deterministic claim is -32 B for every tagged Activity.

The paired timing distributions are noisy: 5 tags are statistically unchanged locally, while 1 and 20 tags trend faster. I am not generalizing a microbenchmark CPU claim from these results. Earlier preliminary 5-tag timing suggested ~4.5%; the higher-confidence paired run supersedes that claim.

Disabled and listener-present-but-unsampled paths allocate zero in both revisions:

Path Base Final Allocation
No listener 40.08 ns 37.32 ns 0 B → 0 B
Listener returns None 32.71 ns 29.15 ns 0 B → 0 B

Those nanosecond timings are noisy; the relevant result is no allocation or structural change on either inactive path.

Exact-final Kestrel support

A minimal in-process Kestrel client/server workload parses a remote W3C parent, starts a sampled server Activity with an OTel-like listener, and adds five tags. Five base/final runs were interleaved; each run used 200,000 requests at concurrency 16.

Scenario Base Final Delta
Sampled + 5 tags, mean 85.278 us/request 83.101 us/request -2.55%
Sampled + 5 tags, allocation 4540.8 B/request 4499.7 B/request -41.1 B
Disabled, mean 85.852 us/request 86.869 us/request +1.18%
Disabled, allocation 3683.5 B/request 3687.1 B/request +3.6 B

Timing is supporting evidence only: this is a custom in-process workload on one machine, not an official ASP.NET benchmark. The sampled allocation reduction is consistent with the deterministic 32-byte Activity saving plus request-level measurement noise. The disabled deltas are noise-sized.

This rerun uses the exact final commit, correcting the preliminary Kestrel provenance caveat noted in the original draft description.

Why this representation

  • One allocation removed, no replacement structure: TagsLinkedList already owns list state and lifetime; co-locating the first node removes the redundant object without adding arrays, dictionaries, pools, or thresholds.
  • Ordering preserved: later nodes append exactly as before. Duplicate keys remain ordered and SetTag/removal retain first-match behavior.
  • Empty invariant: _last is null means empty, First returns null, and the co-located Value is cleared so removed or ignored null-set keys/values are not retained.
  • Concurrency: first publication still uses Interlocked.CompareExchange; losing writers append under the existing list lock. A tracked 1,000-writer stress test verifies all tags are preserved.
  • Formatting: the previous cached StringBuilder field was removed. ValueStringBuilder keeps string/object/null formatting while avoiding a retained builder field on every tagged Activity.
  • No API change: all changes are internal.

Alternatives rejected

  • Caching Activity.Current during sampling did not produce a qualifying exact-parent or E2E benefit and was reverted.
  • Replacing the list with an array/dictionary would increase complexity, alter mutation costs, or threaten ordering/concurrency semantics for a fixed one-object saving.
  • Pooling nodes would add lifetime and retention risk to a ubiquitous tracing primitive.
  • Keeping the cached StringBuilder would preserve a field on every tagged Activity even though formatting is exceptional.

Semantic and test coverage

  • IDs, trace flags/state, W3C and hierarchical parents
  • listener sampling/callback ordering and disposal races
  • Activity.Current / async flow
  • tags, baggage, events, links, exceptions
  • duplicate ordering, set/remove/re-add, enumeration
  • concurrent tag initialization/addition
  • string/object/null formatting
  • empty-list key/value retention

Commands:

./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-restore

Results:

  • Focused ActivityTests: 102 passed
  • Full DiagnosticSource suite: 450 passed
  • Product build: succeeded with no warnings/errors

Evidence provenance

  • Base DLL SHA-256: 33b916c5d29c0258e6c9aa894e7be9e00dc15148893c2c7dba21c80b0a1cf8c6
  • Final DLL SHA-256: 5f5a72d2a01a89fabe45f8fc70e53f70e2eb4f874cfd57b135692a55651549f8
  • Full local raw-evidence index SHA-256: 629f2ed07e5c54cca1d2e47fe1135ea54ec1864f9339df62c5e969c33fc1fa41
  • Test result XML SHA-256: f28c46e09737c1b602259808b7ac854ca46b1e6a360d71f96ae47211749ebb18

Raw local evidence includes Markdown/CSV/full JSON BDN reports, complete logs, immutable binaries/plugins, interleaved Kestrel output, environment and command manifest, source diff, test XML, and exact-final CPU/allocation traces.

Cross-platform EgorBot benchmarking has been requested in a PR comment and is still pending. This PR will remain draft regardless of that result.

Note

This work-in-progress analysis was generated with GitHub Copilot.

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
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @dotnet/area-system-diagnostics-tracing
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 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.TagsLinkedList to inherit from DiagNode<KeyValuePair<string, object?>>, eliminating a separate first-node allocation.
  • Updates TagsLinkedList.ToString() to use ValueStringBuilder instead of a cached StringBuilder.
  • Adds a new test asserting concurrent AddTag calls 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

Comment on lines 1738 to 1749
public void Add(KeyValuePair<string, object?> value)
{
DiagNode<KeyValuePair<string, object?>> newNode = new DiagNode<KeyValuePair<string, object?>>(value);

lock (this)
{
if (_first == null)
if (_last is null)
{
_first = _last = newNode;
Value = value;
_last = this;
return;
}
Comment on lines 1843 to 1850
DiagNode<KeyValuePair<string, object?>> newNode = new DiagNode<KeyValuePair<string, object?>>(value);
if (_first == null)
if (_last is null)
{
_first = _last = newNode;
Value = value;
_last = this;
return;
}

Comment on lines 1714 to 1718
IEnumerator<KeyValuePair<string, object?>> e = list.GetEnumerator();
if (!e.MoveNext())
{
return;
}
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
Copilot AI review requested due to automatic review settings July 13, 2026 09:30

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.

Copilot's findings

  • Files reviewed: 3/3 changed files
  • Comments generated: 2

Comment on lines +259 to +263
tags.GetType().GetMethod("Remove").Invoke(tags, new object[] { "key" });

Assert.Null(tags.GetType().GetProperty("First").GetValue(tags));
Assert.Equal(default, (KeyValuePair<string, object?>)tags.GetType().GetField("Value").GetValue(tags));
}
Comment on lines +278 to +280
MethodInfo add = tags.GetType().GetMethod("Add", new Type[] { typeof(KeyValuePair<string, object>) });
add.Invoke(tags, new object[] { new KeyValuePair<string, object?>("object", 42) });
add.Invoke(tags, new object[] { new KeyValuePair<string, object?>("null", null) });
@artl93

artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -linux_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.

@artl93

artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Local publication benchmark update

These runs use immutable binaries built from:

  • Base 7aa830a03599a8255c2c4abf2947afc5b346cc6f
    • SHA-256 33b916c5d29c0258e6c9aa894e7be9e00dc15148893c2c7dba21c80b0a1cf8c6
  • Final 2a3ea726497b7d04f887a3fdb8afa4c3359bfa1d
    • SHA-256 5f5a72d2a01a89fabe45f8fc70e53f70e2eb4f874cfd57b135692a55651549f8

BenchmarkDotNet 0.16.0-preview.1, .NET 11, Apple M5 Pro/macOS arm64, 3 launches / 8 warmups / 12 iterations:

Tags Base Final Allocation delta Paired timing
0 328 B 328 B 0 B 122.9 → 128.7 ns
1 408 B 376 B -32 B 144.0 → 131.9 ns
5 568 B 536 B -32 B 196.2 → 196.0 ns
20 1168 B 1136 B -32 B 625.1 → 608.9 ns

String and object-valued tags have identical allocation results. No-listener and listener-present-but-unsampled paths remain at zero allocation.

The deterministic result is one fewer 32-byte object per tagged Activity. Timing is noisy: the 5-tag paired result is unchanged, so the earlier preliminary ~4.5% microbenchmark CPU claim is superseded and is not used in the updated description.

Exact-final interleaved Kestrel support (five sampled runs/revision, 200,000 requests/run, concurrency 16):

Scenario Base Final Delta
Sampled + 5 tags 85.278 us 83.101 us -2.55%
Allocation 4540.8 B/request 4499.7 B/request -41.1 B/request

The custom Kestrel timing is supporting evidence only. Cross-platform EgorBot results are pending. This PR remains work in progress.

Note

This benchmark summary was generated with GitHub Copilot.

@artl93

artl93 commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

WIP status

The local evidence pass is complete and the description now contains the exact-revision BDN/Kestrel tables, design invariants, rejected alternatives, commands, tests, and provenance hashes.

Corrections made during this pass:

  • The preliminary ~4.5% 5-tag microbenchmark timing claim was superseded. The higher-confidence paired run shows the 5-tag timing is unchanged locally; -32 B per tagged Activity is the canonical result.
  • Kestrel was rerun against exact final commit 2a3ea726497b7d04f887a3fdb8afa4c3359bfa1d, removing the prior final-ToString provenance caveat.
  • Empty co-located storage now clears removed or ignored keys/values, with tracked regression tests.

Current CI failures are outside this PR's files and both occur in System.Text.Json:

  • Windows net481 compilation: RuntimeFeature.IsDynamicCodeCompiled is unavailable.
  • Mono interpreter Helix: System.Text.Json.Tests aborts on a ReflectionMemberAccessor.CreateParameterizedConstructor debug assertion.

Build Analysis is still running, and cross-platform EgorBot results are pending.

This PR remains a draft / work in progress. Do not review it yet. It will not be marked ready for review.

Note

This work-in-progress status update was generated with GitHub Copilot.

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