|
| 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