Skip to content

Commit 33380a2

Browse files
authored
DSM overhead optimizations (#8450)
# DSM Per-Message Overhead Optimizations ## Summary of changes - **Edge-tag array caching**: Introduced `EdgeTagCache<TKey>` and `BacklogTagCache<TKey>` — process-wide, per-type `ConcurrentDictionary` caches that intern edge-tag arrays and backlog-tag strings so they are only allocated once per unique key (topic/group/cluster combination). - **Node-hash caching**: Added a `NodeHashCacheEntry`/`NodeHashSnapshot` mechanism inside `DataStreamsManager` that memoizes the expensive `CalculateNodeHash` result per `(edgeTags[], nodeHashBase)` pair. Reads are lock-free via a volatile field; writes acquire a per-entry lock only on cache miss or base change. - **Zero-allocation context encode/decode (net core 3.1+)**: Added `PathwayContextEncoder.EncodeInto` and a `Span<byte>`-based `Decode` overload; `DataStreamsContextPropagator` uses `stackalloc` buffers on .NET Core 3.1+ to avoid intermediate `byte[]` heap allocations on every produce/consume. - **Reference-equality dictionary comparers**: `DataStreamsAggregator` and `DataStreamsManager._nodeHashCache` now use reference-equality comparers backed by `RuntimeHelpers.GetHashCode`, which is safe because all keys are interned by the caches above. - **Drain-signal instead of sleep**: Replaced the 10 ms `Thread.Sleep` polling loop in `DataStreamsWriter` with a `ManualResetEventSlim` that wakes immediately when the queue reaches 1 000 items or after a 500 ms timeout, eliminating unnecessary context switches. - **Integration-specific cache-key structs**: Added `readonly struct` cache keys (`ConsumeEdgeTagCacheKey`, `ProduceEdgeTagCacheKey`, `CommitBacklogTagCacheKey`, `ProduceBacklogTagCacheKey`) for Kafka; equivalent structs for AWS SQS/SNS/Kinesis, Azure Service Bus, IBM MQ, and RabbitMQ. - **Minor hot-path fix (Kafka)**: The `Remove(TemporaryBase64PathwayContext)` header scan is now skipped when `KafkaCreateConsumerScopeEnabled=true` (the default), avoiding an O(n) scan on every message. - **`LastConsumePathway` guard removed**: Dropped the redundant `!= null` guard on the produce path that required an `AsyncLocal` read before the actual `AsyncLocal` read. ## Reason for change DSM instrumentation runs on the hot path of every instrumented message. Profiling revealed that the dominant allocations were: 1. A new `string[]` edge-tag array on every produce/consume call. 2. A `CalculateNodeHash` call (hashing over all edge tags) on every checkpoint. 3. Intermediate `byte[]` arrays for pathway context Base64 encoding/decoding. 4. Unnecessary CPU spin from a fixed 10 ms sleep between drain cycles. These optimizations target p99 and throughput benchmarks for Kafka, SQS, SNS, RabbitMQ, IBM MQ, Azure Service Bus, and Kinesis instrumentation. ## Implementation details ### Caching strategy `EdgeTagCache<TKey>` and `BacklogTagCache<TKey>` use the static-generic-class pattern (`static class Foo<T>` with a static field) to give each integration its own dictionary instance without any runtime dispatch. The key type is a `readonly struct` implementing `IEquatable<TKey>`, which prevents boxing in `ConcurrentDictionary` lookups. The caches are bounded at `MaxEdgeTagCacheSize = 1000` entries. Once that limit is reached, new keys are computed on the fly (no caching) to prevent unbounded memory growth from high-cardinality identifiers. ### Node-hash caching `_nodeHashCache` is keyed by `string[]` **identity** (not value equality) because the arrays themselves are interned by `EdgeTagCache<TKey>`. Each entry holds a volatile `NodeHashSnapshot` (`nodeHashBase` + `NodeHash`). On every checkpoint: 1. Look up the array reference — O(1) identity hash. 2. Read the volatile snapshot — lock-free. 3. If the base matches, return immediately. 4. Otherwise, acquire the per-entry lock, double-check, compute, and publish a new snapshot. ### Zero-allocation encode/decode `PathwayContextEncoder.EncodeInto(PathwayContext, Span<byte>)` writes directly into a caller-supplied buffer. `DataStreamsContextPropagator` stackallocs `MaxEncodedSize` (26 bytes) and `MaxBase64EncodedSize` (36 bytes) on the stack and uses `Base64.EncodeToUtf8`/`DecodeFromUtf8` in-place. The only unavoidable allocation is the final `ToArray()` passed to `headers.Add`, because Kafka takes ownership of the byte array. This path is guarded by `#if NETCOREAPP3_1_OR_GREATER`; .NET Framework falls back to the original heap-allocating path. ### Drain signal `DataStreamsWriter` previously slept 10 ms unconditionally between drain iterations, burning CPU and adding ~10 ms latency per batch even under load. The new `ManualResetEventSlim` is signalled immediately when either queue exceeds `DrainThreshold` (1 000 items), capping worst-case latency at `DrainTimeoutMs` (500 ms) while eliminating idle wakeups. ## Test coverage - `DataStreamsManagerTests`: new unit tests verify that `GetOrCreateEdgeTags` and `GetOrCreateBacklogTags` return the **same array/string reference** on repeated calls with the same key, and distinct references for different keys. Tests cover Kafka produce/consume, RabbitMQ produce/consume, and generic key types. - `PathwayContextEncoderTests`: existing encode/decode round-trip tests pass against the new `Span<byte>` overloads. - All existing DSM tests continue to pass. ## Other details - The `MaxEdgeTagCacheSize` constant is `internal` to allow unit tests to verify the overflow/bypass behavior. - No public API surface changes; all new types are `internal`. - `.NET Framework` code paths are unchanged — all `Span`-based optimizations are gated behind `#if NETCOREAPP3_1_OR_GREATER`.
1 parent ac5ec3d commit 33380a2

34 files changed

Lines changed: 694 additions & 70 deletions

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AWS/Kinesis/ContextPropagation.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ public static void InjectTraceIntoData<TRecordRequest>(Tracer tracer, TRecordReq
5858
}
5959

6060
var propagatedContext = new Dictionary<string, object>();
61-
if (scope.Span.Context != null && !string.IsNullOrEmpty(streamName))
61+
if (scope.Span.Context != null && !StringUtil.IsNullOrEmpty(streamName))
6262
{
6363
var dataStreamsManager = tracer.TracerManager.DataStreamsManager;
64-
if (dataStreamsManager != null && dataStreamsManager.IsEnabled)
64+
if (dataStreamsManager is { IsEnabled: true })
6565
{
6666
var payloadSize = jsonData?.Count > 0 && record.Data != null ? record.Data.Length : 0;
67-
var edgeTags = new[] { "direction:out", $"topic:{streamName}", "type:kinesis" };
67+
var edgeTags = dataStreamsManager.GetOrCreateEdgeTags(
68+
new KinesisEdgeTagCacheKey(streamName, IsConsume: false),
69+
static k => ["direction:out", $"topic:{k.StreamName}", "type:kinesis"]);
6870
scope.Span.SetDataStreamsCheckpoint(
6971
dataStreamsManager,
7072
CheckpointKind.Produce,

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AWS/Kinesis/GetRecordsAsyncIntegration.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ internal static TResponse OnAsyncMethodEnd<TTarget, TResponse>(TTarget instance,
6161
var dataStreamsManager = Tracer.Instance.TracerManager.DataStreamsManager;
6262
if (dataStreamsManager is { IsEnabled: true })
6363
{
64-
var edgeTags = new[] { "direction:in", $"topic:{(string)state.State}", "type:kinesis" };
64+
var edgeTags = dataStreamsManager.GetOrCreateEdgeTags(
65+
new KinesisEdgeTagCacheKey((string)state.State, IsConsume: true),
66+
static k => ["direction:in", $"topic:{k.StreamName}", "type:kinesis"]);
6567
foreach (var o in response.Records)
6668
{
6769
var record = o.DuckCast<IRecord>();

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AWS/Kinesis/GetRecordsIntegration.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ internal static CallTargetReturn<TResponse> OnMethodEnd<TTarget, TResponse>(TTar
7878
var dataStreamsManager = Tracer.Instance.TracerManager.DataStreamsManager;
7979
if (dataStreamsManager is { IsEnabled: true })
8080
{
81-
var edgeTags = new[] { "direction:in", $"topic:{(string)state.State}", "type:kinesis" };
81+
var edgeTags = dataStreamsManager.GetOrCreateEdgeTags(
82+
new KinesisEdgeTagCacheKey((string)state.State, IsConsume: true),
83+
static k => ["direction:in", $"topic:{k.StreamName}", "type:kinesis"]);
8284
foreach (var o in response.Records)
8385
{
8486
var record = o.DuckCast<IRecord>();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// <copyright file="KinesisEdgeTagCacheKey.cs" company="Datadog">
2+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
4+
// </copyright>
5+
6+
#nullable enable
7+
8+
namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AWS.Kinesis;
9+
10+
/// <summary>
11+
/// Value-type cache key for Kinesis edge tags. Using a named struct avoids boxing and
12+
/// is compatible with all supported target frameworks.
13+
/// <see cref="IsConsume"/> distinguishes produce (direction:out) from consume (direction:in)
14+
/// so that both directions share a single cache type without key collision.
15+
/// </summary>
16+
internal readonly record struct KinesisEdgeTagCacheKey(string StreamName, bool IsConsume);

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AWS/SNS/AwsSnsHandlerCommon.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@ public static CallTargetState BeforePublish<TPublishRequest>(TPublishRequest req
3434
tags.TopicName = topicName;
3535
}
3636

37-
if (scope?.Span.Context is { } context && !string.IsNullOrEmpty(topicName))
37+
if (scope?.Span.Context is { } context && !StringUtil.IsNullOrEmpty(topicName))
3838
{
3939
var dataStreamsManager = tracer.TracerManager.DataStreamsManager;
40-
// avoid allocation if edgeTags are not going to be used
41-
var edgeTags = dataStreamsManager is { IsEnabled: true } ? ["direction:out", $"topic:{topicName}", "type:sns"] : Array.Empty<string>();
40+
var edgeTags = dataStreamsManager is { IsEnabled: true }
41+
? dataStreamsManager.GetOrCreateEdgeTags(
42+
new SnsEdgeTagCacheKey(topicName),
43+
static k => ["direction:out", $"topic:{k.TopicName}", "type:sns"])
44+
: [];
4245

4346
if (sendType == SendType.SingleMessage)
4447
{
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// <copyright file="SnsEdgeTagCacheKey.cs" company="Datadog">
2+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
4+
// </copyright>
5+
6+
#nullable enable
7+
8+
namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AWS.SNS;
9+
10+
/// <summary>
11+
/// Value-type cache key for SNS produce edge tags. Using a named struct avoids boxing and
12+
/// is compatible with all supported target frameworks.
13+
/// </summary>
14+
internal readonly record struct SnsEdgeTagCacheKey(string TopicName);

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/AWS/SQS/AwsSqsHandlerCommon.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ private static void InjectForSingleMessage<TSendMessageRequest>(Tracer tracer, T
6666
var dataStreamsManager = tracer.TracerManager.DataStreamsManager;
6767
if (dataStreamsManager != null && dataStreamsManager.IsEnabled)
6868
{
69-
var edgeTags = new[] { "direction:out", $"topic:{queueName}", "type:sqs" };
69+
var edgeTags = dataStreamsManager.GetOrCreateEdgeTags(
70+
new SqsEdgeTagCacheKey(queueName, IsConsume: false),
71+
static k => ["direction:out", $"topic:{k.QueueName}", "type:sqs"]);
7072
scope.Span.SetDataStreamsCheckpoint(dataStreamsManager, CheckpointKind.Produce, edgeTags, payloadSizeBytes: 0, timeInQueueMs: 0);
7173
}
7274

@@ -81,14 +83,18 @@ private static void InjectForBatch<TSendMessageBatchRequest>(Tracer tracer, TSen
8183
return;
8284
}
8385

84-
var edgeTags = new[] { "direction:out", $"topic:{queueName}", "type:sqs" };
86+
var dataStreamsManager = tracer.TracerManager.DataStreamsManager;
87+
var edgeTags = dataStreamsManager is { IsEnabled: true }
88+
? dataStreamsManager.GetOrCreateEdgeTags(
89+
new SqsEdgeTagCacheKey(queueName, IsConsume: false),
90+
static k => ["direction:out", $"topic:{k.QueueName}", "type:sqs"])
91+
: [];
8592
foreach (var e in requestProxy.Entries)
8693
{
8794
var entry = e.DuckCast<IContainsMessageAttributes>();
8895
if (entry != null)
8996
{
9097
// this has no effect if DSM is disabled
91-
var dataStreamsManager = tracer.TracerManager.DataStreamsManager;
9298
scope.Span.SetDataStreamsCheckpoint(dataStreamsManager, CheckpointKind.Produce, edgeTags, payloadSizeBytes: 0, timeInQueueMs: 0);
9399
// this needs to be done for context propagation even when DSM is disabled
94100
// (when DSM is enabled, it injects the pathway context on top of the trace context)
@@ -148,7 +154,9 @@ internal static TResponse AfterReceive<TResponse>(TResponse response, Exception?
148154
var dataStreamsManager = Tracer.Instance.TracerManager.DataStreamsManager;
149155
if (dataStreamsManager is { IsEnabled: true })
150156
{
151-
var edgeTags = new[] { "direction:in", $"topic:{(string)state.State}", "type:sqs" };
157+
var edgeTags = dataStreamsManager.GetOrCreateEdgeTags(
158+
new SqsEdgeTagCacheKey((string)state.State, IsConsume: true),
159+
static k => ["direction:in", $"topic:{k.QueueName}", "type:sqs"]);
152160
foreach (var o in response.Messages)
153161
{
154162
var message = o.DuckCast<IMessage>();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// <copyright file="SqsEdgeTagCacheKey.cs" company="Datadog">
2+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
4+
// </copyright>
5+
6+
#nullable enable
7+
8+
namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.AWS.SQS;
9+
10+
/// <summary>
11+
/// Value-type cache key for SQS edge tags. Using a named struct avoids boxing and
12+
/// is compatible with all supported target frameworks.
13+
/// <see cref="IsConsume"/> distinguishes produce (direction:out) from consume (direction:in)
14+
/// so that both directions share a single cache type without key collision.
15+
/// </summary>
16+
internal readonly record struct SqsEdgeTagCacheKey(string QueueName, bool IsConsume);

tracer/src/Datadog.Trace/ClrProfiler/AutoInstrumentation/Azure/ServiceBus/ProcessMessageIntegration.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public sealed class ProcessMessageIntegration
3434
{
3535
private static readonly IDatadogLogger Log = DatadogLogging.GetLoggerFor(typeof(ProcessMessageIntegration));
3636

37+
// Used when the entity path is unknown — direction:in and type:servicebus but no topic tag
38+
private static readonly string[] DefaultConsumeEdgeTags = ["direction:in", "type:servicebus"];
39+
3740
/// <summary>
3841
/// OnMethodBegin callback
3942
/// </summary>
@@ -81,11 +84,12 @@ internal static CallTargetState OnMethodBegin<TTarget, TMessage>(TTarget instanc
8184

8285
var namespaceString = instance.Processor.EntityPath;
8386

84-
// TODO: we could pool these arrays to reduce allocations
8587
// NOTE: the tags must be sorted in alphabetical order
8688
var edgeTags = string.IsNullOrEmpty(namespaceString)
87-
? new[] { "direction:in", "type:servicebus" }
88-
: new[] { "direction:in", $"topic:{namespaceString}", "type:servicebus" };
89+
? DefaultConsumeEdgeTags
90+
: dataStreamsManager.GetOrCreateEdgeTags(
91+
new ServiceBusEdgeTagCacheKey(namespaceString),
92+
static k => ["direction:in", $"topic:{k.EntityPath}", "type:servicebus"]);
8993
var msgSize = dataStreamsManager.IsInDefaultState ? 0 : AzureServiceBusCommon.GetMessageSize(message);
9094
span.SetDataStreamsCheckpoint(
9195
dataStreamsManager,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// <copyright file="ServiceBusEdgeTagCacheKey.cs" company="Datadog">
2+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc.
4+
// </copyright>
5+
6+
#nullable enable
7+
8+
namespace Datadog.Trace.ClrProfiler.AutoInstrumentation.Azure.ServiceBus;
9+
10+
/// <summary>
11+
/// Value-type cache key for Azure Service Bus consume edge tags. Using a named struct avoids boxing and
12+
/// is compatible with all supported target frameworks.
13+
/// </summary>
14+
internal readonly record struct ServiceBusEdgeTagCacheKey(string EntityPath);

0 commit comments

Comments
 (0)