Skip to content

Commit a4ea118

Browse files
aepfliclaude
andauthored
perf: cross-language concurrent comparison benchmarks (#102)
## Summary - Adds X5 16-thread comparison scenarios to Java `ComparisonBenchmark` (old json-logic-java vs new WASM evaluator at high concurrency) - Adds 3-way Python concurrent comparison: PyO3 (native) vs WASM (wasmtime) vs panzi-json-logic at 1/4/16 threads with memory tracking - Completes the cross-language performance comparison matrix across Go, Java, and Python ## Benchmark Results Summary ### Go (wazero WASM vs diegoholiveira/jsonlogic) | Scenario | WASM | Old | Speedup | |----------|------|-----|---------| | Simple 1G | 137 ns | 17,260 ns | **126x** | | Targeting 4G | 1,668 ns | 9,752 ns | **5.8x** | | Large ctx 16G | 2,124 ns | 1,063,688 ns | **500x** | ### Java (Chicory WASM vs json-logic-java) | Scenario | WASM (µs) | Old (µs) | Alloc WASM (B/op) | Alloc Old (B/op) | |----------|-----------|----------|-------------------|-----------------| | Simple 1T | 0.43 | 0.08 | 280 | 328 | | Targeting 4T | 13.6 | 2.3 | 1,120 | 5,024 | | Large ctx 16T | 13.3 | 2.4 | 1,184 | 5,024 | Java old resolver has higher throughput but **4.5x more allocations** per eval. ### Python (PyO3 vs WASM/wasmtime vs panzi-json-logic) | Scenario | PyO3 | WASM | panzi | Best | |----------|------|------|-------|------| | 1T targeting | 3,459 ns | 37,361 ns | 3,115 ns | panzi | | 4T targeting | 1,340 ns | 37,651 ns | 8,100 ns | **PyO3 6x** | | 16T targeting | 1,073 ns | 37,530 ns | 9,854 ns | **PyO3 9.2x** | PyO3 releases GIL → true parallelism at scale. WASM (wasmtime) holds GIL → serial. ## Test plan - [ ] Java: `./mvnw clean package && java -jar target/benchmarks.jar ComparisonBenchmark.X5` - [ ] Python: `cd python && pytest benchmarks/test_concurrent_comparison.py --benchmark-only -v` - [ ] Python standalone: `cd python && python benchmarks/test_concurrent_comparison.py` Closes #101 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ebed59 commit a4ea118

4 files changed

Lines changed: 1166 additions & 235 deletions

File tree

java/src/main/java/dev/openfeature/flagd/evaluator/ComparisonBenchmark.java

Lines changed: 179 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,39 @@
22

33
import dev.openfeature.flagd.evaluator.comparison.MinimalInProcessResolver;
44
import dev.openfeature.sdk.EvaluationContext;
5-
import dev.openfeature.sdk.MutableContext;
5+
import dev.openfeature.sdk.ImmutableContext;
6+
import dev.openfeature.sdk.LayeredEvaluationContext;
67
import dev.openfeature.sdk.ProviderEvaluation;
8+
import dev.openfeature.sdk.Value;
79
import org.openjdk.jmh.annotations.*;
810
import org.openjdk.jmh.infra.Blackhole;
911

12+
import java.util.HashMap;
13+
import java.util.Map;
1014
import java.util.Random;
1115
import java.util.concurrent.TimeUnit;
1216

1317
/**
1418
* JMH benchmarks comparing old JsonLogic resolver vs new WASM evaluator.
1519
*
20+
* <p>Both resolvers use {@link LayeredEvaluationContext} (the production API path),
21+
* which mirrors how the flagd provider constructs contexts with API, client,
22+
* invocation, and transaction layers. This enables context key filtering and
23+
* flag index evaluation on the WASM side.
24+
*
1625
* <p>Covers single-threaded comparison (X1, X2), context size sweep (X3),
17-
* and concurrent comparison (X4).
26+
* concurrent comparison at 4 threads (X4) and 16 threads (X5).
1827
*
1928
* <p><b>Running the benchmarks:</b>
2029
* <pre>
21-
* ./mvnw clean package
22-
* java -jar target/benchmarks.jar ComparisonBenchmark
30+
* ./mvnw clean package -DskipTests
31+
* java -jar target/benchmarks.jar ComparisonBenchmark -prof gc
2332
*
2433
* # Single-threaded only:
2534
* java -jar target/benchmarks.jar "ComparisonBenchmark.X[12]_.*"
2635
*
2736
* # Concurrent only:
28-
* java -jar target/benchmarks.jar "ComparisonBenchmark.X4_.*"
37+
* java -jar target/benchmarks.jar "ComparisonBenchmark.X[45]_.*"
2938
* </pre>
3039
*/
3140
@BenchmarkMode({Mode.Throughput, Mode.AverageTime})
@@ -69,30 +78,32 @@ public class ComparisonBenchmark {
6978
" }\n" +
7079
"}";
7180

72-
// Context JSON strings for the new evaluator
73-
private static final String EMPTY_CONTEXT_JSON = "{}";
81+
/** Build a LayeredEvaluationContext matching the flagd provider pattern. */
82+
private static EvaluationContext layeredContext(
83+
EvaluationContext apiCtx,
84+
EvaluationContext clientCtx,
85+
EvaluationContext invocationCtx) {
86+
return new LayeredEvaluationContext(apiCtx, clientCtx, invocationCtx, ImmutableContext.EMPTY);
87+
}
7488

75-
private static final String SMALL_CONTEXT_JSON = "{" +
76-
"\"targetingKey\": \"user-123\", " +
77-
"\"tier\": \"premium\", " +
78-
"\"role\": \"admin\", " +
79-
"\"region\": \"us-east\", " +
80-
"\"score\": 85" +
81-
"}";
89+
/** Build an ImmutableContext from a targeting key and attribute map. */
90+
private static ImmutableContext immutableCtx(String targetingKey, Map<String, Value> attrs) {
91+
return new ImmutableContext(targetingKey, attrs);
92+
}
8293

8394
@State(Scope.Benchmark)
8495
public static class ComparisonState {
8596
FlagEvaluator newEvaluator;
8697
MinimalInProcessResolver oldResolver;
8798

88-
// EvaluationContext objects for the old resolver
99+
// Shared API-level context (global attributes like environment/service)
100+
ImmutableContext apiContext;
101+
102+
// Layered contexts for benchmarks
89103
EvaluationContext emptyContext;
90104
EvaluationContext smallContext;
91105
EvaluationContext largeContext;
92106

93-
// JSON strings for the new evaluator
94-
String largeContextJson;
95-
96107
@Setup(Level.Trial)
97108
public void setup() {
98109
try {
@@ -104,67 +115,48 @@ public void setup() {
104115
oldResolver = new MinimalInProcessResolver();
105116
oldResolver.loadFlags(FLAG_CONFIG);
106117

107-
// Build contexts for old resolver
108-
emptyContext = new MutableContext();
109-
110-
smallContext = new MutableContext()
111-
.add("targetingKey", "user-123")
112-
.add("tier", "premium")
113-
.add("role", "admin")
114-
.add("region", "us-east")
115-
.add("score", 85);
116-
117-
MutableContext large = new MutableContext()
118-
.add("targetingKey", "user-123")
119-
.add("tier", "premium")
120-
.add("role", "admin")
121-
.add("region", "us-east")
122-
.add("score", 85);
123-
for (int i = 0; i < 100; i++) {
124-
switch (i % 4) {
125-
case 0:
126-
large.add("attr_" + i, "value-" + i);
127-
break;
128-
case 1:
129-
large.add("attr_" + i, i * 7);
130-
break;
131-
case 2:
132-
large.add("attr_" + i, i % 2 == 0);
133-
break;
134-
case 3:
135-
large.add("attr_" + i, i * 1.5);
136-
break;
137-
}
138-
}
139-
largeContext = large;
140-
141-
// Build large context JSON for the new evaluator
142-
StringBuilder sb = new StringBuilder(4096);
143-
sb.append("{");
144-
sb.append("\"targetingKey\": \"user-123\", ");
145-
sb.append("\"tier\": \"premium\", ");
146-
sb.append("\"role\": \"admin\", ");
147-
sb.append("\"region\": \"us-east\", ");
148-
sb.append("\"score\": 85");
118+
// API-level context (set once at provider init)
119+
Map<String, Value> apiAttrs = new HashMap<>();
120+
apiAttrs.put("environment", new Value("production"));
121+
apiAttrs.put("service", new Value("checkout"));
122+
apiContext = new ImmutableContext(apiAttrs);
123+
124+
// Empty invocation context — only API-level attributes
125+
emptyContext = layeredContext(apiContext, ImmutableContext.EMPTY, ImmutableContext.EMPTY);
126+
127+
// Small invocation context (4 user attributes)
128+
Map<String, Value> smallAttrs = new HashMap<>();
129+
smallAttrs.put("tier", new Value("premium"));
130+
smallAttrs.put("role", new Value("admin"));
131+
smallAttrs.put("region", new Value("us-east"));
132+
smallAttrs.put("score", new Value(85));
133+
smallContext = layeredContext(apiContext, ImmutableContext.EMPTY,
134+
immutableCtx("user-123", smallAttrs));
135+
136+
// Large invocation context (100+ attributes)
137+
Map<String, Value> largeAttrs = new HashMap<>();
138+
largeAttrs.put("tier", new Value("premium"));
139+
largeAttrs.put("role", new Value("admin"));
140+
largeAttrs.put("region", new Value("us-east"));
141+
largeAttrs.put("score", new Value(85));
149142
for (int i = 0; i < 100; i++) {
150-
sb.append(", ");
151143
switch (i % 4) {
152144
case 0:
153-
sb.append("\"attr_").append(i).append("\": \"value-").append(i).append("\"");
145+
largeAttrs.put("attr_" + i, new Value("value-" + i));
154146
break;
155147
case 1:
156-
sb.append("\"attr_").append(i).append("\": ").append(i * 7);
148+
largeAttrs.put("attr_" + i, new Value(i * 7));
157149
break;
158150
case 2:
159-
sb.append("\"attr_").append(i).append("\": ").append(i % 2 == 0 ? "true" : "false");
151+
largeAttrs.put("attr_" + i, new Value(i % 2 == 0));
160152
break;
161153
case 3:
162-
sb.append("\"attr_").append(i).append("\": ").append(i * 1.5);
154+
largeAttrs.put("attr_" + i, new Value(i * 1.5));
163155
break;
164156
}
165157
}
166-
sb.append("}");
167-
largeContextJson = sb.toString();
158+
largeContext = layeredContext(apiContext, ImmutableContext.EMPTY,
159+
immutableCtx("user-123", largeAttrs));
168160
} catch (Exception e) {
169161
throw new RuntimeException("Failed to setup comparison state", e);
170162
}
@@ -195,7 +187,7 @@ public void X1_old_simple(ComparisonState state, Blackhole bh) {
195187
public void X1_new_simple(ComparisonState state, Blackhole bh) {
196188
try {
197189
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
198-
Boolean.class, "simple-bool", EMPTY_CONTEXT_JSON);
190+
Boolean.class, "simple-bool", state.emptyContext);
199191
bh.consume(result.getValue());
200192
bh.consume(result.getVariant());
201193
} catch (Exception e) {
@@ -220,7 +212,7 @@ public void X2_old_targeting(ComparisonState state, Blackhole bh) {
220212
public void X2_new_targeting(ComparisonState state, Blackhole bh) {
221213
try {
222214
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
223-
Boolean.class, "targeted-access", SMALL_CONTEXT_JSON);
215+
Boolean.class, "targeted-access", state.smallContext);
224216
bh.consume(result.getValue());
225217
bh.consume(result.getVariant());
226218
} catch (Exception e) {
@@ -265,7 +257,7 @@ public void X3_old_largeContext(ComparisonState state, Blackhole bh) {
265257
public void X3_new_emptyContext(ComparisonState state, Blackhole bh) {
266258
try {
267259
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
268-
Boolean.class, "targeted-access", EMPTY_CONTEXT_JSON);
260+
Boolean.class, "targeted-access", state.emptyContext);
269261
bh.consume(result.getValue());
270262
bh.consume(result.getVariant());
271263
} catch (Exception e) {
@@ -278,7 +270,7 @@ public void X3_new_emptyContext(ComparisonState state, Blackhole bh) {
278270
public void X3_new_smallContext(ComparisonState state, Blackhole bh) {
279271
try {
280272
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
281-
Boolean.class, "targeted-access", SMALL_CONTEXT_JSON);
273+
Boolean.class, "targeted-access", state.smallContext);
282274
bh.consume(result.getValue());
283275
bh.consume(result.getVariant());
284276
} catch (Exception e) {
@@ -291,7 +283,7 @@ public void X3_new_smallContext(ComparisonState state, Blackhole bh) {
291283
public void X3_new_largeContext(ComparisonState state, Blackhole bh) {
292284
try {
293285
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
294-
Boolean.class, "targeted-access", state.largeContextJson);
286+
Boolean.class, "targeted-access", state.largeContext);
295287
bh.consume(result.getValue());
296288
bh.consume(result.getVariant());
297289
} catch (Exception e) {
@@ -305,18 +297,56 @@ public void X3_new_largeContext(ComparisonState state, Blackhole bh) {
305297

306298
@State(Scope.Thread)
307299
public static class ThreadContext {
308-
Random random;
309-
String simpleContextJson;
300+
EvaluationContext simpleContext;
310301
EvaluationContext matchingContext;
302+
EvaluationContext largeMatchingContext;
311303

312304
@Setup(Level.Trial)
313305
public void setup() {
314-
random = new Random(42 + Thread.currentThread().getId());
315-
simpleContextJson = "{\"targetingKey\": \"user-" + random.nextInt(10000) + "\"}";
316-
matchingContext = new MutableContext()
317-
.add("role", "admin")
318-
.add("tier", "premium")
319-
.add("targetingKey", "user-" + random.nextInt(10000));
306+
Random random = new Random(42 + Thread.currentThread().getId());
307+
String targetingKey = "user-" + random.nextInt(10000);
308+
309+
// API-level context (same as ComparisonState)
310+
Map<String, Value> apiAttrs = new HashMap<>();
311+
apiAttrs.put("environment", new Value("production"));
312+
apiAttrs.put("service", new Value("checkout"));
313+
ImmutableContext apiCtx = new ImmutableContext(apiAttrs);
314+
315+
// Simple: only targeting key, no invocation attributes
316+
simpleContext = layeredContext(apiCtx, ImmutableContext.EMPTY,
317+
new ImmutableContext(targetingKey));
318+
319+
// Matching: role + tier that match the targeting rule
320+
Map<String, Value> matchAttrs = new HashMap<>();
321+
matchAttrs.put("role", new Value("admin"));
322+
matchAttrs.put("tier", new Value("premium"));
323+
matchingContext = layeredContext(apiCtx, ImmutableContext.EMPTY,
324+
immutableCtx(targetingKey, matchAttrs));
325+
326+
// Large: 100+ attributes with role + tier
327+
Map<String, Value> largeAttrs = new HashMap<>();
328+
largeAttrs.put("role", new Value("admin"));
329+
largeAttrs.put("tier", new Value("premium"));
330+
largeAttrs.put("region", new Value("us-east"));
331+
largeAttrs.put("score", new Value(85));
332+
for (int i = 0; i < 100; i++) {
333+
switch (i % 4) {
334+
case 0:
335+
largeAttrs.put("attr_" + i, new Value("value-" + i));
336+
break;
337+
case 1:
338+
largeAttrs.put("attr_" + i, new Value(i * 7));
339+
break;
340+
case 2:
341+
largeAttrs.put("attr_" + i, new Value(i % 2 == 0));
342+
break;
343+
case 3:
344+
largeAttrs.put("attr_" + i, new Value(i * 1.5));
345+
break;
346+
}
347+
}
348+
largeMatchingContext = layeredContext(apiCtx, ImmutableContext.EMPTY,
349+
immutableCtx(targetingKey, largeAttrs));
320350
}
321351
}
322352

@@ -328,7 +358,7 @@ public void setup() {
328358
@Threads(4)
329359
public void X4_old_concurrentSimple(ComparisonState state, ThreadContext ctx, Blackhole bh) {
330360
ProviderEvaluation<Boolean> result = state.oldResolver.booleanEvaluation(
331-
"simple-bool", false, state.emptyContext);
361+
"simple-bool", false, ctx.simpleContext);
332362
bh.consume(result);
333363
}
334364

@@ -337,7 +367,7 @@ public void X4_old_concurrentSimple(ComparisonState state, ThreadContext ctx, Bl
337367
public void X4_new_concurrentSimple(ComparisonState state, ThreadContext ctx, Blackhole bh) {
338368
try {
339369
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
340-
Boolean.class, "simple-bool", ctx.simpleContextJson);
370+
Boolean.class, "simple-bool", ctx.simpleContext);
341371
bh.consume(result.getValue());
342372
bh.consume(result.getVariant());
343373
} catch (Exception e) {
@@ -366,6 +396,73 @@ public void X4_new_concurrentTargeting(ComparisonState state, ThreadContext ctx,
366396
}
367397
}
368398

399+
// ========================================================================
400+
// X5: Old vs New under high concurrency (16 threads)
401+
// ========================================================================
402+
403+
@Benchmark
404+
@Threads(16)
405+
public void X5_old_16t_simple(ComparisonState state, ThreadContext ctx, Blackhole bh) {
406+
ProviderEvaluation<Boolean> result = state.oldResolver.booleanEvaluation(
407+
"simple-bool", false, ctx.simpleContext);
408+
bh.consume(result);
409+
}
410+
411+
@Benchmark
412+
@Threads(16)
413+
public void X5_new_16t_simple(ComparisonState state, ThreadContext ctx, Blackhole bh) {
414+
try {
415+
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
416+
Boolean.class, "simple-bool", ctx.simpleContext);
417+
bh.consume(result.getValue());
418+
bh.consume(result.getVariant());
419+
} catch (Exception e) {
420+
throw new RuntimeException("Benchmark failed", e);
421+
}
422+
}
423+
424+
@Benchmark
425+
@Threads(16)
426+
public void X5_old_16t_targeting(ComparisonState state, ThreadContext ctx, Blackhole bh) {
427+
ProviderEvaluation<Boolean> result = state.oldResolver.booleanEvaluation(
428+
"targeted-access", false, ctx.matchingContext);
429+
bh.consume(result);
430+
}
431+
432+
@Benchmark
433+
@Threads(16)
434+
public void X5_new_16t_targeting(ComparisonState state, ThreadContext ctx, Blackhole bh) {
435+
try {
436+
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
437+
Boolean.class, "targeted-access", ctx.matchingContext);
438+
bh.consume(result.getValue());
439+
bh.consume(result.getVariant());
440+
} catch (Exception e) {
441+
throw new RuntimeException("Benchmark failed", e);
442+
}
443+
}
444+
445+
@Benchmark
446+
@Threads(16)
447+
public void X5_old_16t_largeContext(ComparisonState state, ThreadContext ctx, Blackhole bh) {
448+
ProviderEvaluation<Boolean> result = state.oldResolver.booleanEvaluation(
449+
"targeted-access", false, ctx.largeMatchingContext);
450+
bh.consume(result);
451+
}
452+
453+
@Benchmark
454+
@Threads(16)
455+
public void X5_new_16t_largeContext(ComparisonState state, ThreadContext ctx, Blackhole bh) {
456+
try {
457+
EvaluationResult<Boolean> result = state.newEvaluator.evaluateFlag(
458+
Boolean.class, "targeted-access", ctx.largeMatchingContext);
459+
bh.consume(result.getValue());
460+
bh.consume(result.getVariant());
461+
} catch (Exception e) {
462+
throw new RuntimeException("Benchmark failed", e);
463+
}
464+
}
465+
369466
/**
370467
* Main method to run benchmarks standalone.
371468
*/

0 commit comments

Comments
 (0)