-
Notifications
You must be signed in to change notification settings - Fork 4.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[API Proposal]: System.Diagnostics.Activity: Enumeration API [Iteration 2] #67207
Comments
Tagging subscribers to this area: @dotnet/area-system-diagnostics-activity Issue DetailsFirst proposal: #67072 Background and motivationOne of the main things the OTel .NET SDK needs to do is export What would be great is if the .NET API exposed a public method for enumerating these elements without allocation and as /cc @reyang @cijothomas API ProposalThe idea is to expose a base class which users may use (with a cast) to enumerate namespace System.Diagnostics
{
// New
public abstract class DiagnosticEnumerable<T> : System.Collections.Generic.IEnumerable<T>
{
public abstract Enumerator GetEnumerator();
System.Collections.Generic.IEnumerator<T> System.Collections.Generic.IEnumerable<T>.GetEnumerator() { throw null; }
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; }
public struct Enumerator : System.Collections.Generic.IEnumerator<T>
{
public readonly ref T Current { get { throw null; } }
T System.Collections.Generic.IEnumerator<T>.Current { get { throw null; } }
object? System.Collections.IEnumerator.Current { get { throw null; } }
public bool MoveNext() { throw null; }
public void Reset() { }
public void Dispose() { }
}
}
} API Usage public int EnumerateTagsUsingNewApi()
{
DiagnosticEnumerable<KeyValuePair<string, object>> enumerable = (DiagnosticEnumerable<KeyValuePair<string, object>>)this.activity.TagObjects;
int count = 0;
foreach (ref readonly KeyValuePair<string, object> tag in enumerable)
{
if (tag.Value != null)
{
count++;
}
}
return count;
} Example implementationDiff: https://github.com/dotnet/runtime/compare/main...CodeBlanch:activity-enumerator?expand=1 Alternatives / considerationsOriginally I wanted to change the return types on the Not part of the proposal, but desired: namespace System.Diagnostics
{
public abstract class Activity
{
- public System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string, object?>> TagObjects { get { throw null; } }
- public System.Collections.Generic.IEnumerable<System.Diagnostics.ActivityEvent> Events { get { throw null; } }
- public System.Collections.Generic.IEnumerable<System.Diagnostics.ActivityLink> Links { get { throw null; } }
+ public System.Diagnostics.DiagnosticEnumerable<System.Collections.Generic.KeyValuePair<string, object?>> TagObjects { get { throw null; } }
+ public System.Diagnostics.DiagnosticEnumerable<System.Diagnostics.ActivityEvent> Events { get { throw null; } }
+ public System.Diagnostics.DiagnosticEnumerable<System.Diagnostics.ActivityLink> Links { get { throw null; } }
}
} Instead of forcing a cast we could modify the namespace System.Diagnostics
{
public abstract class Activity
{
public System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string, object?>> TagObjects { get { throw null; } }
public System.Collections.Generic.IEnumerable<System.Diagnostics.ActivityEvent> Events { get { throw null; } }
public System.Collections.Generic.IEnumerable<System.Diagnostics.ActivityLink> Links { get { throw null; } }
+ public System.Diagnostics.DiagnosticEnumerable<System.Collections.Generic.KeyValuePair<string, object?>> TagObjectsEnumerable { get { throw null; } }
+ public System.Diagnostics.DiagnosticEnumerable<System.Diagnostics.ActivityEvent> EventsEnumerable { get { throw null; } }
+ public System.Diagnostics.DiagnosticEnumerable<System.Diagnostics.ActivityLink> LinksEnumerable { get { throw null; } }
}
} Testing + benchmarks.NET 6 API w/ OTel SDK
.NET 7 API w/ OTel SDKNote: .NET 7 build contains already merged #67012 so the base cases are seeing some improvement from that optimization.
* The proposed API implementation does not break the existing OTel SDK reflection/IL generation. Benchmark codeusing System;
using System.Collections.Generic;
using System.Diagnostics;
using BenchmarkDotNet.Attributes;
using OpenTelemetry.Trace;
namespace Benchmarks.Trace
{
[MemoryDiagnoser]
public class ActivityEnumerationBenchmarks : IDisposable
{
private readonly ActivitySource source = new("Source");
private readonly Activity activity;
public ActivityEnumerationBenchmarks()
{
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
ActivitySource.AddActivityListener(new ActivityListener
{
ActivityStarted = null,
ActivityStopped = null,
ShouldListenTo = (activitySource) => activitySource.Name == "Source",
Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllDataAndRecorded,
});
this.activity = this.source.StartActivity("test");
this.activity.SetTag("tag1", "value1");
this.activity.SetTag("tag2", "value2");
this.activity.SetTag("tag3", "value3");
this.activity.SetTag("tag4", "value4");
this.activity.SetTag("tag5", "value5");
this.activity.Stop();
}
public void Dispose()
{
this.source.Dispose();
}
[Benchmark]
public int EnumerateTags()
{
int count = 0;
foreach (KeyValuePair<string, object> tag in this.activity.TagObjects)
{
if (tag.Value != null)
{
count++;
}
}
return count;
}
[Benchmark]
public int EnumerateTagsUsingReflection()
{
TagEnumerationState state = default;
this.activity.EnumerateTags(ref state);
return state.Count;
}
[Benchmark]
public int EnumerateTagsUsingNewApi()
{
DiagnosticEnumerable<KeyValuePair<string, object>> enumerable = (DiagnosticEnumerable<KeyValuePair<string, object>>)this.activity.TagObjects;
int count = 0;
foreach (ref readonly KeyValuePair<string, object> tag in enumerable)
{
if (tag.Value != null)
{
count++;
}
}
return count;
}
private struct TagEnumerationState : IActivityEnumerator<KeyValuePair<string, object>>
{
public int Count;
public bool ForEach(KeyValuePair<string, object> activityTag)
{
if (activityTag.Value != null)
{
this.Count++;
}
return true;
}
}
}
}
|
@noahfalk @reyang @cijothomas this proposal looks good to me. any feedback? |
LGTM 👍 |
If DiagnosticEnumerable<T>.Enumerator.Reset() is going to throw NotSupportedException, it should preferably be an explicit interface implementation. |
^ It this part of the contract of |
@cijothomas It is more of an implementation detail, but critical to making this work! It does worry me that someone in the future could come along and not understand that and break things. If you check out the "Alternatives / considerations" section in the description, we could make the contract strongly typed by introducing some new properties. It would be nice to change the existing ones, but that would be breaking sadly 💔 I thought about doing something like this... +public interface ITracingDataContainer
+{
+ public DiagnosticEnumerable<KeyValuePair<string, object?>> TagObjects { get { throw null; } }
+ public DiagnosticEnumerable<ActivityEvent> Events { get { throw null; } }
+ public DiagnosticEnumerable<ActivityLink> Links { get { throw null; } }
+}
public class Activity
+ : ITracingDataContainer
{
+ DiagnosticEnumerable<KeyValuePair<string, object?>> ITracingDataContainer.TagObjects { get { throw null; } }
+ DiagnosticEnumerable<ActivityEvent> ITracingDataContainer.Events { get { throw null; } }
+ DiagnosticEnumerable<ActivityLink> ITracingDataContainer.Links { get { throw null; } }
} So we have an interface on public int EnumerateTagsUsingNewApi()
{
ITracingDataContainer tracingDataContainer = this.activity;
int count = 0;
foreach (ref readonly KeyValuePair<string, object> tag in tracingDataContainer.TagObjects)
{
if (tag.Value != null)
{
count++;
}
}
return count;
}
|
We should be adding tests to ensure no breaking will happen in the future. I am not seeing this will be a problem if we add such tests. Also, the consuming code (e.g., OpenTelemetry) can be written in a safe way too. |
or for a non-nested option
|
I prefer not to have it at least for now to avoid increasing the confusion for the Activity users and it is not worth it to introduce a new Type just to support that. |
@noahfalk suggestion is a good idea to have the new classes defined inside the Activity class. @CodeBlanch if you agree, could you please update the proposal accordingly? |
@tarekgh I don't have an issue with it, but one thing I wanted to bring up. If we do... public class Activity
{
public class Enumerable<T> {}
public class Enumerator<T> {}
} Then internal sealed class DiagLinkedList<T> : Activity.Enumerable<T>
{
public override Activity.Enumerator<T> GetEnumerator() => new Activity.Enumerator<T>(_first);
} So a type outside of |
Thinking more about that, I am inclining to go back to the original proposal having the type @noahfalk what you think? |
My main qualm about "DiagnosticEnumerable" is that the name is very non-specific. I think of this as a specific optimization for a narrow use-case rather than a general purpose exchange type that would have wide usage. For example in other cases we might want the enumerable to be backed by an array, a list, a segmented list, etc. A couple on thoughts on where we could go...
internal class DiagLinkedList { ... }
public struct ActivityItemEnumerable<T> : IEnumerable<T>
{
DiagLinkedList _linkedList;
public void ActivityItemEnumerator GetEnumerator() => _linkedList.GetEnumerator();
// forward other APIs as needed
}
// we find a second scenario that also wants to enumerate the DiagLinkedList implementation, but we want to give
// it a name unrelated to Activity and reserve the right to change its implementation independent of Activity
public struct SomeOtherEnumerable<T> : IEnumerable<T>
{
DiagLinkedList _linkedList;
// forward APIs as needed
} |
I was not thinking of using it that broad. I was thinking it can be used in the Activity/Metrics scenarios. I mean is it scoped to System.Diaganostics.DiagnosticSource library scenarios.
That is what I meant. But will your proposal here require exposing a new type (like Anyway, as I am not sure if we'll use this type in other scenarios outside Activity, I can live with |
I think having (potentially) multiple types is the cost we pay for keeping the implementation abstracted from users. These are the options I can find so far:
public interface IByRefEnumerable<T>
{
IByRefEnumerator<T> GetEnumerator();
}
public interface IByRefEnumerator<T>
{
ref T Current { get; }
bool MoveNext();
} The downside of the interfaces is that we have to allocate when calling GetEnumerator() to return back a reference type.
Option 3 seems the best to me, but if there is another option I am missing or you guys think one of the other options is a better tradeoff I'm open to it. I don't think there is any option which is clearly the best across all criteria we care about. |
If you ever change some properties of Activity to use a backing type other than DiagLinkedList, you can in principle do that without breaking uses of struct DiagnosticEnumerator, by adding runtime checks for supporting a small set of other backing types. And introduce the more specific enumerable/enumerator types in that version so that users have a way to avoid the runtime checks. |
My vote would be for 1 or 3 from above. For 3, |
Ok, let's go with |
Updated |
Rather than expose an abstract class to describe the concept, we feel that it's solvable with just one struct iterator type defined on Activity namespace System.Diagnostics
{
partial class Activity
{
public Enumerator<KeyValuePair<string,object>> EnumerateTagObjects();
public Enumerator<ActivityLink> EnumerateLinks();
public Enumerator<ActivityEvent> EnumerateEvents();
public struct Enumerator<T>
{
private Enumerator(/* The node */) { }
public readonly Enumerator<T> GetEnumerator() => this;
public readonly ref T Current => ref _node.Value;
public bool MoveNext() { ... }
}
}
} |
@tarekgh I updated the implementation branch I have been using for the new API surface. One hiccup, in order to return the new |
One of the questions brought up on the design review was (more or less): Does this lead to a measurable impact on the export process inside the OpenTelemetry SDK? Here are some benchmarks to try and answer that question. Scenarios:
Exporter: Jaeger, which writes its data to a buffer that is reused across all export calls. I tried to pick somewhat realistic cases. No cases included for links because I don't think those are used much today. Even though it is the biggest struct (?) and might benefit the most from being passed around as ref. Using .NET 6 IEnumerable APIs:
Using .NET 6 IEnumerable API with reflection engine:
Using local build of .NET 7 with proposed API*:
*Still using the reflection engine to enumerate ActivityEvent.Tags because that API is not part of this proposal. If this gets approved/merged we can work on that as well. |
First proposal: #67072
Background and motivation
One of the main things the OTel .NET SDK needs to do is export
Activity
(trace) data. There is a lot of enumeration needed to do that.Activity
tags, links(+tags), and events(+tags). Today the .NET API is allIEnumerable<T>
/IEnumerator<T>
based and all of the items being enumerated arereadonly struct
s. Our performance testing was showing a lot of allocations incurred using the enumeration interfaces. To avoid that we generate IL to bind to the internalstruct
contracts. That fixes the allocations but a lot time is currently being spent copying value types.What would be great is if the .NET API exposed a public method for enumerating these elements without allocation and as
readonly ref
to avoid copying./cc @reyang @cijothomas
API Proposal
The idea is to expose a base class which users may use (with a cast) to enumerate
Activity
data more efficiently:API Usage
Example implementation
Diff: https://github.com/dotnet/runtime/compare/main...CodeBlanch:activity-enumerator?expand=1
Alternatives / considerations
Originally I wanted to change the return types on the
Activity
class but chatting with @tarekgh that would be a breaking change. Decided the cast would work well enough.Not part of the proposal, but desired:
Instead of forcing a cast we could modify the
Activity
API to expose a strongly typed API at the cost of some duplication/confusion:Testing + benchmarks
.NET 6 API w/ OTel SDK
.NET 7 API w/ OTel SDK
Note: .NET 7 build contains already merged #67012 so the base cases are seeing some improvement from that optimization.
* The proposed API implementation does not break the existing OTel SDK reflection/IL generation.
Benchmark code
The text was updated successfully, but these errors were encountered: