You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ValidateTraceState re-parses and rebuilds the tracestate value on every call, even when Activity.TraceStateString is typically set once for the lifetime of a span and its children. A single incoming request that fanned out to several outgoing calls would redo the same work work for each.
Adds a last-(input,output)-pair cache to skip re-parsing when the value hasn't changed since the last call on the same thread.
I also had Claude Code verify that the changes work well across many requests like in an HTTP server to avoid cache thrashing. The code and results for that are shown below.
Scenario
main (3 runs)
main median
PR (3 runs)
PR median
8t, burst=1
119.1 / 108.1 / 121.0 ns
119.1 ns
162.6 / 131.2 / 101.2 ns
131.2 ns
8t, burst=3
324.6 / 159.5 / 341.2 ns
324.6 ns
245.4 / 293.3 / 125.5 ns
245.4 ns
8t, burst=10
76.4 / 352.1 / 86.1 ns
86.1 ns
168.5 / 163.7 / 216.4 ns
168.5 ns
16t, burst=1
171.0 / 247.5 / 136.3 ns
171.0 ns
152.9 / 138.9 / 236.4 ns
152.9 ns
16t, burst=3
290.6 / 245.3 / 228.2 ns
245.3 ns
199.1 / 200.6 / 209.8 ns
200.6 ns
16t, burst=10
664.6 / 353.9 / 332.8 ns
353.9 ns
273.4 / 266.3 / 274.2 ns
273.4 ns
Harness code
usingSystem.Diagnostics;// Models two things:// 1. The originally-raised scenario: many concurrent threads, each with its own distinct// tracestate, racing against a shared cache slot (addressed by using [ThreadStatic]).// 2. Review finding: a real thread-pool thread doesn't use one tracestate forever - it serves a// SEQUENCE of different requests over its lifetime, each request making a 'burst' of a few// fan-out calls with the SAME value before the thread moves on to a different request (and a// different value). This models that with a per-thread pool of 'valuesPerThread' distinct// values, held for 'burstSize' consecutive calls each before cycling to the next.// burstSize=1 is the worst case (every call is a different request - always a cache miss); a// large burstSize approaches the (unrealistically generous) "one value forever" case.DistributedContextPropagatorpropagator=DistributedContextPropagator.CreateDefaultPropagator();varsetter=newDistributedContextPropagator.PropagatorSetterCallback(static(object?carrier,stringname,stringvalue)=>((Dictionary<string,string>)carrier!)[name]=value);constintIterationsPerThread=100_000;doubleRunScenario(stringlabel,intthreadCount,intvaluesPerThread,intburstSize){// Each scenario allocates on the order of hundreds of MB total across its threads (more for// the no-cache baseline). Settle the heap first so one scenario's GC pressure doesn't bleed// into the next scenario's timing within the same process.GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);GC.WaitForPendingFinalizers();GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);varactivitiesPerThread=newActivity[threadCount][];varcarriers=newDictionary<string,string>[threadCount];usingvarsource=newActivitySource("contention-check");usingvarlistener=newActivityListener{ShouldListenTo= _ =>true,Sample=(refActivityCreationOptions<ActivityContext>_)=>ActivitySamplingResult.AllData,};ActivitySource.AddActivityListener(listener);for(intt=0;t<threadCount;t++){varactivities=newActivity[valuesPerThread];for(intv=0;v<valuesPerThread;v++){Activityactivity=source.StartActivity("op")!;// Every (thread, value-slot) combination gets its own distinct tracestate, so no two// threads and no two values within a thread's rotation ever coincidentally collide.activity.TraceStateString=$"thread{t}_v{v}=t61rcWkgMzE{v},rojo{v}=00f067aa0ba902b7{v},vendor3_{v}=abcdef1234567890{v}";activities[v]=activity;}activitiesPerThread[t]=activities;carriers[t]=newDictionary<string,string>();}varthreads=newThread[threadCount];varbarrier=newBarrier(threadCount+1);long[]allocatedPerThread=newlong[threadCount];for(intt=0;t<threadCount;t++){intthreadIndex=t;threads[t]=newThread(()=>{Activity[]activities=activitiesPerThread[threadIndex];Dictionary<string,string>carrier=carriers[threadIndex];barrier.SignalAndWait();// release pointlongbefore=GC.GetAllocatedBytesForCurrentThread();intvalueIndex=0;intburstRemaining=burstSize;for(inti=0;i<IterationsPerThread;i++){propagator.Inject(activities[valueIndex],carrier,setter);if(--burstRemaining==0){valueIndex=(valueIndex+1)%activities.Length;burstRemaining=burstSize;}}longafter=GC.GetAllocatedBytesForCurrentThread();allocatedPerThread[threadIndex]=after-before;});threads[t].Start();}barrier.SignalAndWait();// main thread releases all workers at (roughly) the same instantvarsw=Stopwatch.StartNew();foreach(Threadthinthreads)th.Join();sw.Stop();foreach(Activity[]activitiesinactivitiesPerThread)foreach(Activityainactivities)a.Dispose();longtotalAllocated=0;foreach(longainallocatedPerThread)totalAllocated+=a;longtotalOps=(long)threadCount*IterationsPerThread;doublensPerOp=sw.Elapsed.TotalMilliseconds*1_000_000/totalOps;doublebytesPerOp=totalAllocated/(double)totalOps;Console.WriteLine($"{label,-58} threads={threadCount,-3}{sw.ElapsedMilliseconds,6} ms total {nsPerOp,8:F1} ns/op {bytesPerOp,8:F1} B/op (total alloc {totalAllocated/1024.0:F0} KB)");returnnsPerOp;}RunScenario("(warmup)",1,1,int.MaxValue);Console.WriteLine("--- Cross-thread isolation only (one value per thread, forever - NOT a server-workload claim) ---");RunScenario("Single thread, same value (best case)",1,1,int.MaxValue);RunScenario("8 threads, 1 value/thread forever",8,1,int.MaxValue);RunScenario("16 threads, 1 value/thread forever",16,1,int.MaxValue);Console.WriteLine("--- Realistic thread-pool reuse: each thread cycles through 8/16 distinct values ---");RunScenario("8 threads, 8 values/thread, burst=1",8,8,1);RunScenario("8 threads, 8 values/thread, burst=3",8,8,3);RunScenario("8 threads, 8 values/thread, burst=10",8,8,10);RunScenario("16 threads, 16 values/thread, burst=1",16,16,1);RunScenario("16 threads, 16 values/thread, burst=3",16,16,3);RunScenario("16 threads, 16 values/thread, burst=10",16,16,10);
`ValidateTraceState` re-parses and rebuilds the `tracestate` value on every call, even when `Activity.TraceStateString` is typically set once for the lifetime of a span and its children. A single incoming request that fanned out to several outgoing calls would redo the same work work for each.
Adds a last-(input,output)-pair cache to skip re-parsing when the value hasn't changed since the last call on the same thread.
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.
Please place these thread-static fields with the other fields/constants at the top of W3CPropagator. Declaring fields between methods makes the type layout harder to scan and conflicts with this repository's C# rule that fields are declared before other members.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ValidateTraceStatere-parses and rebuilds thetracestatevalue on every call, even whenActivity.TraceStateStringis typically set once for the lifetime of a span and its children. A single incoming request that fanned out to several outgoing calls would redo the same work work for each.Adds a last-(input,output)-pair cache to skip re-parsing when the value hasn't changed since the last call on the same thread.
Benchmarks
Models one downstream
HttpClientcall's header injection when the current span'sTraceStateStringis unchanged from a prior call in the same fan-out.Benchmark code
I also had Claude Code verify that the changes work well across many requests like in an HTTP server to avoid cache thrashing. The code and results for that are shown below.
Harness code