Skip to content

Commit 8760c68

Browse files
committed
Add microbenchmarks to exercise Context API for both old and new implementations
1 parent 10cb67f commit 8760c68

3 files changed

Lines changed: 451 additions & 0 deletions

File tree

dd-trace-core/build.gradle

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,15 @@ dependencies {
115115
jmh {
116116
jmhVersion = libs.versions.jmh.get()
117117
duplicateClassesStrategy = DuplicatesStrategy.EXCLUDE
118+
if (project.hasProperty('jmhIncludes')) {
119+
includes = [project.jmhIncludes]
120+
}
121+
if (project.hasProperty('jmhProfilers')) {
122+
profilers = [project.jmhProfilers]
123+
}
124+
if (project.hasProperty('jmhJvm')) {
125+
jvm = project.javaToolchains.launcherFor {
126+
languageVersion = JavaLanguageVersion.of(project.jmhJvm as int)
127+
}.map { it.executablePath.asFile.absolutePath }
128+
}
118129
}
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
package datadog.context;
2+
3+
import static java.util.concurrent.TimeUnit.MICROSECONDS;
4+
import static java.util.concurrent.TimeUnit.SECONDS;
5+
6+
import datadog.trace.core.scopemanager.ContinuableScopeManager;
7+
import java.util.concurrent.CompletableFuture;
8+
import java.util.concurrent.ExecutorService;
9+
import java.util.concurrent.Executors;
10+
import java.util.concurrent.Semaphore;
11+
import java.util.concurrent.ThreadFactory;
12+
import org.openjdk.jmh.annotations.Benchmark;
13+
import org.openjdk.jmh.annotations.BenchmarkMode;
14+
import org.openjdk.jmh.annotations.Fork;
15+
import org.openjdk.jmh.annotations.Level;
16+
import org.openjdk.jmh.annotations.Measurement;
17+
import org.openjdk.jmh.annotations.Mode;
18+
import org.openjdk.jmh.annotations.OutputTimeUnit;
19+
import org.openjdk.jmh.annotations.Param;
20+
import org.openjdk.jmh.annotations.Scope;
21+
import org.openjdk.jmh.annotations.Setup;
22+
import org.openjdk.jmh.annotations.State;
23+
import org.openjdk.jmh.annotations.TearDown;
24+
import org.openjdk.jmh.annotations.Threads;
25+
import org.openjdk.jmh.annotations.Warmup;
26+
27+
/**
28+
* Compares {@link ThreadLocalContextManager} vs {@link ContinuableScopeManager} across context
29+
* attach, swap, and cross-thread continuation scenarios — including virtual threads (requires JDK
30+
* 21+).
31+
*
32+
* <p>For the same-non-root-context stack-depth scenario see {@link ContextManagerDepthBenchmark}.
33+
*
34+
* <p>Run with:
35+
*
36+
* <pre>
37+
* {@code ./gradlew :dd-trace-core:jmh -PjmhIncludes=ContextManagerBenchmark -PjmhJvm=25 -PjmhProfilers=gc}
38+
* </pre>
39+
*/
40+
@State(Scope.Benchmark)
41+
@Warmup(iterations = 3, time = 1)
42+
@Measurement(iterations = 5, time = 1)
43+
@BenchmarkMode(Mode.Throughput)
44+
@Threads(8)
45+
@OutputTimeUnit(MICROSECONDS)
46+
@Fork(value = 1)
47+
public class ContextManagerBenchmark {
48+
49+
// ── Constants ──────────────────────────────────────────────────────────────
50+
51+
// Reflective access to Thread.ofVirtual().factory() (Java 21+).
52+
// Used to create fixed-size pools of virtual threads so no new VT is spawned per task.
53+
// Falls back to platform threads on older JVMs — the benchmark still runs, but
54+
// captureAndResumeOnVirtualThread and captureAndFanOutToVirtualThreads will measure
55+
// platform-thread overhead instead.
56+
static final boolean VIRTUAL_THREADS_AVAILABLE;
57+
static final ThreadFactory VIRTUAL_OR_PLATFORM_FACTORY;
58+
59+
static {
60+
ThreadFactory factory = null;
61+
try {
62+
Object builder = Thread.class.getMethod("ofVirtual").invoke(null);
63+
factory = (ThreadFactory) builder.getClass().getMethod("factory").invoke(builder);
64+
} catch (Exception ignored) {
65+
}
66+
VIRTUAL_THREADS_AVAILABLE = factory != null;
67+
VIRTUAL_OR_PLATFORM_FACTORY = factory != null ? factory : Thread::new;
68+
}
69+
70+
// Creates a fixed pool whose threads are virtual (Java 21+) or platform (older JVMs).
71+
// Using a fixed pool rather than newVirtualThreadPerTaskExecutor avoids spawning a
72+
// fresh virtual thread on every task submission, keeping thread-creation cost out of
73+
// the measured critical path.
74+
static ExecutorService newFixedVirtualPool(int nThreads) {
75+
return Executors.newFixedThreadPool(nThreads, VIRTUAL_OR_PLATFORM_FACTORY);
76+
}
77+
78+
static final ContextKey<String> KEY = ContextKey.named("benchmark-key");
79+
// power of 2 so cycling wraps cheaply with bit-mask
80+
static final int CONTEXT_COUNT = 16;
81+
// virtual threads spawned per continuation fan-out
82+
static final int FAN_OUT = 8;
83+
84+
// ── Parameters ─────────────────────────────────────────────────────────────
85+
86+
/**
87+
* Which {@link ContextManager} implementation to benchmark.
88+
*
89+
* <p>{@code ThreadLocal} — {@link ThreadLocalContextManager} (the lightweight default).
90+
*
91+
* <p>{@code Continuable} — {@link ContinuableScopeManager} (the full scope/span manager).
92+
*/
93+
@Param({"ThreadLocal", "Continuable"})
94+
public String managerType;
95+
96+
// ── Benchmark-scoped shared state ─────────────────────────────────────────
97+
98+
ContextManager manager;
99+
// CONTEXT_COUNT distinct non-root contexts; threads cycle through them to
100+
// avoid artificial same-context hits in benchmarks that don't want them
101+
Context[] contexts;
102+
103+
@Setup
104+
public void setup() {
105+
manager = createManager(managerType);
106+
contexts = createContexts();
107+
}
108+
109+
static ContextManager createManager(String type) {
110+
return "Continuable".equals(type)
111+
? new ContinuableScopeManager(0, false)
112+
: ThreadLocalContextManager.INSTANCE;
113+
}
114+
115+
static Context[] createContexts() {
116+
Context[] contexts = new Context[CONTEXT_COUNT];
117+
for (int i = 0; i < CONTEXT_COUNT; i++) {
118+
contexts[i] = Context.root().with(KEY, "value-" + i);
119+
}
120+
return contexts;
121+
}
122+
123+
// ── Per-thread state ───────────────────────────────────────────────────────
124+
125+
@State(Scope.Thread)
126+
public static class ThreadState {
127+
int index;
128+
// Pre-allocated barrier reused across fan-out invocations.
129+
// Avoids a new CountDownLatch allocation per invocation that would inflate gc.alloc.rate.norm.
130+
final Semaphore fanOutBarrier = new Semaphore(0);
131+
ExecutorService platformExecutor;
132+
ExecutorService virtualExecutor;
133+
134+
@Setup(Level.Trial)
135+
public void setup() {
136+
// Both pools are fixed-size so no new thread is created per submitted task.
137+
// The virtual pool uses virtual threads (Java 21+) or falls back to platform threads.
138+
// Pool size is intentionally larger than the JMH thread count to avoid executor starvation
139+
// when benchmark threads all submit tasks concurrently.
140+
platformExecutor = Executors.newFixedThreadPool(16);
141+
virtualExecutor = newFixedVirtualPool(16);
142+
}
143+
144+
@TearDown(Level.Trial)
145+
public void tearDown() throws InterruptedException {
146+
platformExecutor.shutdown();
147+
virtualExecutor.shutdown();
148+
platformExecutor.awaitTermination(10, SECONDS);
149+
virtualExecutor.awaitTermination(10, SECONDS);
150+
}
151+
152+
Context nextContext(Context[] contexts) {
153+
return contexts[(index++) & (CONTEXT_COUNT - 1)];
154+
}
155+
}
156+
157+
// ── Thread state with a pre-attached context (for read benchmarks) ─────────
158+
159+
/**
160+
* Attaches a context once per trial so that {@link #current} and {@link #currentAndGet} measure
161+
* only the read path, not the attach overhead.
162+
*/
163+
@State(Scope.Thread)
164+
public static class ActiveContextState {
165+
ContextScope scope;
166+
167+
@Setup(Level.Trial)
168+
public void setup(ContextManagerBenchmark benchmark) {
169+
scope = benchmark.manager.attach(benchmark.contexts[0]);
170+
}
171+
172+
@TearDown(Level.Trial)
173+
public void tearDown() {
174+
scope.close();
175+
}
176+
}
177+
178+
// ── Scenario 1: attach a different context, close scope ───────────────────
179+
180+
/** Attach one distinct context then close its scope. The hot path for most instrumentations. */
181+
@Benchmark
182+
public void attachAndClose(ThreadState thread) {
183+
Context ctx = thread.nextContext(contexts);
184+
try (ContextScope scope = manager.attach(ctx)) {
185+
// scope is active
186+
}
187+
}
188+
189+
// ── Scenario 2: nested attach of two different contexts ───────────────────
190+
191+
/**
192+
* Attach two distinct contexts in sequence and close both. Exercises the stack push/pop cycle
193+
* that occurs at every instrumented method boundary.
194+
*/
195+
@Benchmark
196+
public void nestedAttachAndClose(ThreadState thread) {
197+
Context outer = thread.nextContext(contexts);
198+
Context inner = thread.nextContext(contexts);
199+
try (ContextScope outerScope = manager.attach(outer)) {
200+
try (ContextScope innerScope = manager.attach(inner)) {
201+
// inner is active
202+
}
203+
}
204+
}
205+
206+
// ── Scenario 3: swap different contexts ───────────────────────────────────
207+
208+
/**
209+
* Swap in a new context then swap back the previous one. {@link
210+
* ContinuableScopeManager#swap(Context)} replaces the entire scope stack, making this a heavier
211+
* operation than in {@link ThreadLocalContextManager}.
212+
*
213+
* <p>Note: GCProfiler will show allocation asymmetry here by design. {@link
214+
* ContinuableScopeManager} swap allocates a {@code ScopeStack}, a {@code ContinuableScope}, and a
215+
* {@code ScopeContext} per invocation; {@link ThreadLocalContextManager} swap is a plain field
216+
* write. That asymmetry is the real cost of each manager's swap operation, not scaffolding.
217+
*/
218+
@Benchmark
219+
public void swapContexts(ThreadState thread) {
220+
Context ctx = thread.nextContext(contexts);
221+
Context previous = manager.swap(ctx);
222+
manager.swap(previous);
223+
}
224+
225+
// ── Scenario 4: capture + same-thread resume (continuation baseline) ───────
226+
227+
/**
228+
* Capture the current context as a continuation and immediately resume it on the same thread.
229+
* Establishes the allocation and atomic-counter cost of the continuation mechanism without any
230+
* cross-thread scheduling overhead.
231+
*/
232+
@Benchmark
233+
public void captureThenResumeSameThread(ThreadState thread) {
234+
Context ctx = thread.nextContext(contexts);
235+
try (ContextScope scope = manager.attach(ctx)) {
236+
ContextContinuation cont = manager.capture(ctx);
237+
try (ContextScope resumed = cont.resume()) {
238+
// context restored on the same thread
239+
}
240+
}
241+
}
242+
243+
// ── Scenario 5: capture, resume on a platform thread ─────────────────────
244+
245+
/**
246+
* Capture the current context as a continuation and resume it on a pooled platform thread.
247+
* Measures cross-thread handoff latency (submit + schedule + execute) for each manager.
248+
*
249+
* <p>Fewer JMH threads than the default so the platform executor is never saturated.
250+
*/
251+
@Benchmark
252+
@Threads(4)
253+
public void captureAndResumeOnPlatformThread(ThreadState thread) throws Exception {
254+
captureAndResumeOnExecutor(thread, thread.platformExecutor);
255+
}
256+
257+
// ── Scenario 6: capture, resume on a virtual thread ──────────────────────
258+
259+
/**
260+
* Capture the current context as a continuation and resume it on a fixed-pool virtual thread.
261+
* Shows how well each manager scales when continuations are used for structured concurrency or
262+
* reactive pipelines on virtual threads.
263+
*/
264+
@Benchmark
265+
@Threads(4)
266+
public void captureAndResumeOnVirtualThread(ThreadState thread) throws Exception {
267+
captureAndResumeOnExecutor(thread, thread.virtualExecutor);
268+
}
269+
270+
private void captureAndResumeOnExecutor(ThreadState thread, ExecutorService executor)
271+
throws Exception {
272+
Context ctx = thread.nextContext(contexts);
273+
try (ContextScope scope = manager.attach(ctx)) {
274+
ContextContinuation cont = manager.capture(ctx);
275+
CompletableFuture.runAsync(
276+
() -> {
277+
try (ContextScope resumed = cont.resume()) {
278+
// context propagated to executor thread
279+
}
280+
},
281+
executor)
282+
.get(10, SECONDS);
283+
}
284+
}
285+
286+
// ── Scenario 7: fan-out — one held continuation resumed on N virtual threads
287+
288+
/**
289+
* Capture one context, hold the continuation, then fan it out to {@value #FAN_OUT} virtual
290+
* threads concurrently. Each virtual thread resumes the same continuation and closes its scope;
291+
* only the explicit {@link ContextContinuation#release()} after the barrier completes the
292+
* lifecycle.
293+
*
294+
* <p>This reflects async frameworks that dispatch a single request context to a pool of worker
295+
* coroutines / virtual threads.
296+
*
297+
* <p>Uses {@link Mode#SampleTime} to capture percentile tail latency in addition to the mean.
298+
* Warmup and measurement windows are extended because each invocation waits for {@value #FAN_OUT}
299+
* round-trips before returning.
300+
*/
301+
@Benchmark
302+
@Threads(2)
303+
@BenchmarkMode(Mode.SampleTime)
304+
@Warmup(iterations = 3, time = 3)
305+
@Measurement(iterations = 5, time = 5)
306+
public void captureAndFanOutToVirtualThreads(ThreadState thread) throws Exception {
307+
Context ctx = thread.nextContext(contexts);
308+
try (ContextScope scope = manager.attach(ctx)) {
309+
ContextContinuation cont = manager.capture(ctx).hold();
310+
Semaphore barrier = thread.fanOutBarrier;
311+
for (int i = 0; i < FAN_OUT; i++) {
312+
thread.virtualExecutor.execute(
313+
() -> {
314+
try (ContextScope resumed = cont.resume()) {
315+
// each virtual thread sees the same captured context
316+
} finally {
317+
barrier.release();
318+
}
319+
});
320+
}
321+
try {
322+
if (!barrier.tryAcquire(FAN_OUT, 10, SECONDS)) {
323+
throw new IllegalStateException("fan-out timed out");
324+
}
325+
} finally {
326+
cont.release();
327+
}
328+
}
329+
}
330+
331+
// ── Scenario 8: read the current context ─────────────────────────────────
332+
333+
/**
334+
* Returns the currently active context. The most frequent operation in any traced application —
335+
* called at every instrumented method boundary before reading a span or key.
336+
*/
337+
@Benchmark
338+
public Context current(ActiveContextState active) {
339+
return manager.current();
340+
}
341+
342+
// ── Scenario 9: read a value from the current context ────────────────────
343+
344+
/**
345+
* Returns a value from the currently active context. The full "read active span" path that
346+
* instrumentation executes at every traced method boundary.
347+
*/
348+
@Benchmark
349+
public Object currentAndGet(ActiveContextState active) {
350+
return manager.current().get(KEY);
351+
}
352+
}

0 commit comments

Comments
 (0)