Benchmarking Rule Engine Throughput

This how-to is part of Optimizing Rule Engine Performance. Before you can claim a rule engine change made evaluation faster, you need a benchmark you trust — one that warms the engine, measures only the evaluation, drives a realistic rule mix, and reports percentiles instead of a misleading average. This how-to builds that benchmark so your optimization work rests on real numbers, not a hot-loop artifact.

The stakes are higher than they look. A flag evaluation sits on the request path of every feature it guards, so an engine that spends 50 microseconds instead of 5 quietly taxes every endpoint that reads a flag — and at hundreds of evaluations per request, that tax compounds. The trouble is that flag evaluation is fast enough that naive timing is dominated by measurement error: at sub-microsecond speeds, the clock resolution, the loop overhead, and the JIT warmup can each be larger than the thing you are trying to measure. A benchmark that ignores those effects will happily report a 3x improvement that vanishes in production, or hide a regression behind noise. The four disciplines below — warm, isolate, realistic mix, percentiles — exist precisely because each one removes a class of measurement lie that would otherwise let you ship the wrong conclusion.

A trustworthy benchmark shape A sound benchmark warms the engine first, isolates the evaluation call from setup and I/O, drives a realistic mix of rule complexity, and reports p50, p99, and throughput rather than a single average. warm up discard first N isolate eval no setup / IO real mix rule complexity percentiles p50/p99, tput
Skip any of these four and the number lies: no warmup measures the JIT, no isolation measures I/O, an unreal mix measures the wrong path, and an average hides the tail.

Prerequisites

Benchmark prerequisites The engine callable in isolation, a representative rule and context sample, and a per-call timing harness. Engine in isolation no live fetch Real rule sample + contexts Timing harness per-call
A representative rule sample matters most — benchmarking one trivial boolean tells you nothing about the nested rules that actually cost, and a per-call timing harness on a quiet, pinned CPU is what keeps the sample from being drowned in scheduler noise.

Step-by-Step Procedure

Step 1 — Warm the engine before measuring

Run several thousand untimed evaluations first so the JIT has compiled the hot path and any caches are populated, then measure. Cold numbers describe startup, not steady state. On a JIT runtime — the JVM’s C2 compiler, V8’s TurboFan, or PyPy — the first few thousand calls run interpreted or lightly optimized, and the tiered compiler only promotes the method to fully optimized native code after it crosses an invocation threshold. Measure before that promotion and you record the interpreter’s speed, which can be an order of magnitude slower than the code you actually ship. Even on a purely interpreted runtime like CPython, warmup still matters: the first pass populates rule caches, resolves attribute lookups, and pulls the working set into the L1/L2 cache, so the cold call pays for memory traffic that every later call avoids.

How many warmup iterations is enough depends on the runtime’s compilation thresholds — 20,000 is a safe default that clears CPython’s cache-fill and comfortably exceeds the JVM’s default -XX:CompileThreshold of 10,000. The reliable test is empirical: plot per-call latency against iteration number and warm until the curve flattens. If it never flattens, you have a deeper problem — an unbounded cache, a growing allocation, or a slow memory leak — and that is worth finding before you benchmark anything.

# warmup.py
for _ in range(20_000):
    engine.evaluate(sample_rule, sample_ctx)   # untimed — warm the JIT/caches

Step 2 — Isolate the evaluation from setup and I/O

Build the context and fetch the rule once, outside the timed loop, so you measure the evaluation itself and not JSON parsing or a network call. This is where most homegrown benchmarks go wrong: they wrap a timer around client.getBooleanValue(...), which internally deserializes the flag config, constructs an evaluation context, and only then runs the rule. You end up timing the SDK’s plumbing — often the dominant cost — instead of the rule engine you set out to measure. Separating setup from the measured region is what lets you attribute a change to the right layer.

Keep the timed region as small as the language allows. Read the clock immediately before the call and immediately after, and do nothing else inside — no formatting, no logging, no list append that triggers a resize mid-loop. Preallocate the timings buffer to length N so the append never reallocates while you are measuring, and prefer a monotonic, high-resolution clock (time.perf_counter_ns in Python, System.nanoTime on the JVM) over wall-clock time, which can jump backward when NTP adjusts. If the evaluation itself performs I/O — a remote lookup, a segment fetch from a store — that I/O belongs in a different benchmark; a rule engine microbenchmark should touch only in-memory state, or the network jitter will swamp the signal you care about.

# isolate.py — time only engine.evaluate()
rule = engine.compile(load_rule())     # setup outside the loop
ctx = build_context(sample)            # setup outside the loop
timings = []
for _ in range(N):
    t = time.perf_counter_ns()
    engine.evaluate(rule, ctx)         # the ONLY thing timed
    timings.append(time.perf_counter_ns() - t)

Step 3 — Drive a realistic rule-complexity mix

Weight the benchmark toward the distribution of rule complexity you actually run — mostly simple, some nested — so the aggregate reflects production, not a single worst or best case. A rule engine’s cost is not uniform across rules: a bare boolean returns in a handful of nanoseconds, a segment match walks a list and may hash an attribute, and a deeply nested AND/OR tree with regex or semantic-version comparators can cost ten or a hundred times more. If your benchmark is 100% trivial booleans, you will optimize the cheap path and miss the nested rules that dominate your real p99. If it is 100% worst-case, you will chase a tail that almost never fires and possibly regress the common path to help it.

Derive the mix from real data, not intuition. Sample the flag keys your service actually reads over a representative window — a day that spans your traffic patterns — and bucket them by the shape of their targeting rules, then set the weights to match that histogram. The 70/25/5 split above is only an illustration; your service might be 95% simple booleans because most flags are kill switches, or skewed toward segment matches if you do heavy cohort targeting. Getting the weights wrong tilts every downstream percentile, so treat the mix as a measured input, not a guess.

# mix.py — sample rules by production frequency
RULE_MIX = [(simple_bool, 0.7), (segment_match, 0.25), (nested_and_or, 0.05)]
def next_rule():
    r = random.random(); acc = 0
    for rule, weight in RULE_MIX:
        acc += weight
        if r < acc: return rule
    return RULE_MIX[-1][0]

Step 4 — Report percentiles and throughput

Compute p50, p99, and evaluations per second. The average hides the tail that determines user-facing latency; p99 is the number that matters on a hot path. A distribution with a great mean and an ugly tail is common in rule engines: garbage-collection pauses, occasional cache misses, and the rare nested rule all live in the upper percentiles, and a single request that reads dozens of flags will hit that tail with near certainty. If you serve a million requests a day and each reads twenty flags, you evaluate twenty million times daily — your p99 fires two hundred thousand times, so it is not an edge case, it is a routine event you are on the hook for.

Report the throughput figure with care, because “evaluations per second” is ambiguous. The single-threaded figure — one core, one loop — is a clean measure of per-call cost and the right number for comparing engine changes. The aggregate figure your service actually delivers depends on core count, lock contention, and allocator behavior under concurrency, and it does not scale linearly with cores. State which one you are quoting. And beware the mean-based throughput in the snippet: dividing by the average latency inflates the number whenever the distribution is skewed, so a throughput derived from the median is often the more honest headline. Whichever you pick, publish p50 and p99 alongside it — a throughput number with no percentiles is a marketing figure, not an engineering one.

# report.py
timings.sort()
p50 = timings[len(timings)//2] / 1000       # us
p99 = timings[int(len(timings)*0.99)] / 1000
throughput = 1e9 / (sum(timings)/len(timings))  # eval/s
print(f"p50={p50:.2f}us p99={p99:.2f}us throughput={throughput:,.0f}/s")
The four benchmarking steps Warm the engine, isolate the evaluation from setup and IO, drive a realistic rule-complexity mix, and report percentiles and throughput. 1 Warm discard cold 2 Isolate eval only 3 Real mix weighted 4 Percentiles p99 + tput
Report p99, not the mean — a rule engine can have a great average and a tail that blows your latency budget on the nested rules.

Verification

Run the benchmark twice unchanged and confirm the p99 is stable between runs; a benchmark whose numbers swing wildly is measuring noise, not the engine. Stability is the precondition for every conclusion you will draw: if the run-to-run variance is 15% and the optimization you are evaluating buys 8%, the benchmark literally cannot see your change, and any number it prints is a coin flip dressed up as data. Establish the noise floor first — run the identical binary several times, note the spread in p99 — and only trust a measured improvement once it clears that spread by a comfortable margin.

If the numbers refuse to settle, the machine is usually the culprit before the code is. Disable CPU frequency scaling and turbo boost so the clock speed does not drift under thermal load, pin the benchmark to an isolated core with taskset or the equivalent, close the noisy neighbors — browsers, sync daemons, container siblings — and prefer bare metal over a burstable cloud instance whose CPU credits throttle mid-run. On a shared CI runner you will rarely get clean numbers, which is why an absolute-latency gate in CI tends to flap; compare relative deltas against a baseline measured on the same host in the same run instead.

python bench.py; python bench.py
# p50=0.41us p99=1.9us throughput=2,300,000/s
# p50=0.42us p99=2.0us throughput=2,280,000/s   <- stable => trustworthy
Stable across runs Two consecutive runs produce nearly identical p50 and p99, indicating the benchmark measures the engine rather than noise. run 1: p99 1.9us 2.30M/s run 2: p99 2.0us 2.28M/s
Stability run-to-run is the credibility test — an optimization is only real if the delta exceeds the run-to-run variance.

Gotchas & Edge Cases

Three benchmarking edge cases Dead-code elimination removing the evaluation, a single-context benchmark hitting a hot branch predictor, and microbenchmark numbers not reflecting concurrent load. Dead-code elim result unused → consume it Single context branch predictor vary the inputs No concurrency micro != prod load-test too
Dead-code elimination is the sneakiest — a compiler that sees the result is unused can delete the evaluation entirely, giving you an impossibly fast number.

Troubleshooting & FAQ

My benchmark says millions of evals per second but production is slower. Why?

The microbenchmark measures a warm, single-threaded, contention-free path; production adds concurrency, cache pressure, and real context construction. Use the microbenchmark to compare engine changes relatively, and a load test to predict absolute production throughput.

Should I benchmark the compiled rule or the raw rule?

Benchmark what production evaluates. If you precompile rules into an AST, benchmark the compiled form, with compilation done once outside the timed loop — because that is exactly what happens at runtime.

How many iterations are enough?

Enough that the p99 stabilizes between runs — typically hundreds of thousands to millions for a sub-microsecond operation. Increase N until two consecutive runs agree to within a few percent; that convergence is your signal the sample is large enough.

Should I use a benchmarking framework or a hand-rolled loop?

Prefer an established framework — JMH on the JVM, pytest-benchmark or timeit in Python, criterion in Rust, Go’s built-in testing.B. They solve the hard parts you would otherwise get wrong: they defeat dead-code elimination, auto-tune iteration counts until the measurement stabilizes, run isolated forks to avoid cross-contamination, and report percentiles rather than a bare mean. A hand-rolled loop is fine for a quick relative check, but for a number you will cite in a decision, lean on a tool that has already been hardened against the traps in the gotchas above.

Does the benchmark belong in CI, and will it catch regressions?

A microbenchmark on a shared CI runner is too noisy for an absolute-latency gate — CPU credits, neighbors, and frequency scaling make the number flap. What works is a relative gate: measure a fixed baseline and your candidate in the same job, on the same host, back to back, and fail only when the candidate is slower by a margin that comfortably exceeds the run-to-run variance. That catches gross regressions without crying wolf on noise.

How do I benchmark the rule engine without the SDK wrapper around it?

Call the engine’s evaluation function directly rather than going through the client’s getBooleanValue-style API, which bundles context construction, type coercion, hook execution, and telemetry into the measured region. Most engines expose the core evaluate(rule, context) entry point; compile the rule and build the context once outside the loop, then time only that call. If you must go through the SDK, at least disable hooks and telemetry so you are not benchmarking your own instrumentation.

Why is my p99 much worse than my p50, even for simple rules?

A wide gap between median and tail almost always points to something periodic rather than the rule itself — a GC pause, a background thread stealing the core, a cache line bouncing between CPUs, or occasional context reconstruction. Profile the slow samples specifically: capture the timestamps of the calls above p99 and correlate them with GC logs or scheduler events. Nine times out of ten the tail is an artifact of the environment or allocation pattern, not the evaluation logic, and fixing it means reducing per-call allocation or quieting the machine rather than touching the engine.