← All posts

Measured benchmark · JDK 21 · Corretto · macOS · 200M events per run

Greg Higgins #fluxtion#java#performance#benchmarks

What one event costs

Taking a generated event processor apart, nanosecond by nanosecond — and pricing each abstraction separately.

8.44 ns per event means nothing on its own. Eight nanoseconds compared with what? The number worth having is the comparison against a person writing the same thing by hand:

Hand-written, same semantics7.12ns/event
Generated dispatch8.44ns/event
The abstraction costs+1.32ns · 19%
Steady-state allocation0bytes/event

Generated orchestration costs about 1.3 ns per event over hand-writing the equivalent orchestration — with execution order derived from the declared graph rather than authored as a separate representation. That is the result; 118M events/sec is a consequence of it.

The author describes what depends on what; the compiler derives what must happen as a consequence. Author is deliberately broad — a person, a model, a resolver, generated configuration. Fluxtion requires an explicit graph at compile time; it does not care who wrote it. This article prices the derivation.

Method, before any number

  • Ten nodes — one event handler, eight compute nodes, and a detector that arrests roughly half of all cycles, so the conditional tail is exercised.

  • One event object, mutated and re-fired, so the stream contributes no garbage. Node state is double/long only.

  • Generated ahead of time, run with only the runtime on the classpath — no builder, no compiler. In-memory compilation allocates heavily and would otherwise dominate.

  • Every arm asserted to produce identical externally observable output — the detector's count and the final published value. Internal invocation counts deliberately differ where a feature is switched off.

  • 200,000,000 events per run, 5 JVM runs for the headline table, median reported. 5,000,000 events of warm-up through the same code path first.

  • Allocation read from ThreadMXBean.getCurrentThreadAllocatedBytes() immediately before and after the timed loop on the same thread; collections from GarbageCollectorMXBean.getCollectionCount() over the same window.

  • Single core, closed loop, no coordinated-omission correction — tails under a paced arrival rate would differ.

The ladder

Implementationns/eventEvents/sec
Hand-written, one inline method
Different semantics — no guarding
3.15317,682,191
Hand-written, dirty flags + guards
The like-for-like comparator
7.12140,465,221
Generated, stream-driven clock8.44118,491,836
Generated, default wall clock17.6056,820,765

Every arm allocated 0 bytes per event. The inline arm is a useful physical floor but does not buy the same semantics — it has no guarding, so the equivalent execution order has to be represented explicitly rather than derived from the graph.

Where the time actually goes

The more useful output of this benchmark is not a throughput figure but a decomposition. Each row is a capability priced separately, by ablation:

CapabilityApprox. costNotes
Raw hand-written unconditional work~3 nsThe arithmetic itself
Generated unconditional machinery~2.5 nsEntry, dispatch, re-entrancy guard
Dirty / conditional propagation~2.5 nsThe feature that arrests the tail
Node and service infrastructure~0.2 nsNegligible in this fixture
Deterministic replay clock~0.3 nsThe abstraction, once the wall-clock read is gone
Default wall-clock read~9 nsOne implementation choice, not the abstraction
Runtime audit2× to 25×See below — the expensive one

Zero steady-state allocation, demonstrated two ways

First: 500,000,000 events under -XX:+UseEpsilonGC -Xmx64m — a collector that never frees a byte. A sustained byte per event would have needed 500MB. Zero collections; the run completed.

Second, and more convincing: allocation is constant rather than proportional.

EventsBytes allocatedBytes/event
10,000,00035,0080.0035
40,000,00035,0080.0009
160,000,00035,0080.0002

Startup allocation exists; event-count-dependent allocation does not. What this bounds is a sustained byte per event — sufficiently rare or fractional amortised allocation could still pass. It is also a result for this ten-node graph, not a proof for larger graphs: JIT escape analysis can eliminate allocations in a small, closed fixture that might survive in a larger application. The Epsilon run independently shows that allocation did not exhaust a 64 MB heap here; scaling remains a prediction.

The clock is the single biggest lever

The default configuration stamps a wall-clock time on every event whether or not any node reads it. Ablating the three per-event auditors one at a time isolates it exactly:

Per-event auditor fan-out containsns/eventDelta
Nothing7.76
Node-name lookup only7.83+0.07
Service registry only7.88+0.12
Clock only17.19+9.43
All three, as shipped17.20+9.44

The fix is one lambda — supply a clock strategy driven by the stream rather than the wall:

flow.setClockStrategy(() -> streamTime);

Every node then reads that value, so a replay produces timestamps identical to the original run. The harness reads the injected value back through the node-visible clock before measuring, so this arm demonstrably uses it rather than merely being fast.

The 9 ns is the wall-clock read itself: System.currentTimeMillis() measures 12.32 ns/call standalone here. The experiment isolates timestamp acquisition, not the operating-system mechanism, and no claim is made about the latter.

Once there is nothing to collect, GC pauses leave the problem

CollectorEvents/secp50Collections
Epsilon
Never collects
56,663,29417 ns0
G155,797,38017 ns0
Serial
512MB heap
55,101,27917 ns0
ZGC46,476,34220 ns0

No collector ever ran, so collection activity and pause time drop out of the problem entirely. The collector implementation does not: ZGC is ~17% slower while also collecting nothing.

50-event batch timing distribution

Every figure below is a percentile of the MEAN of 50 consecutive events, not of individual events. System.nanoTime() costs ~16 ns for a pair against an ~8 ns operation, so per-event timing would measure the timer. Averaging hides outliers by construction: one 5 µs event among 49 fast ones would not appear here. This shows steady-state shape; it does not resolve a single-event tail.

Armp50p90p99p99.9p99.99Max
Hand-written, guarded78101025430
Generated, stream clock891120301,814
Generated, default clock171924391112,316

The generated stream-clock arm has a slightly higher p99.99 batch mean — 30 ns against 25 — and a maximum of 1,814 ns against 430, more than four times the hand-written maximum. This harness does not explain that outlier, and batch means prevent reconstructing the individual event that caused it. One machine and a closed loop are not enough to claim tail-latency equivalence. The result needs a paced, per-event latency experiment before it belongs in a latency-sensitive trading claim.

Dirty filtering, and a benchmark trap

Turning conditional propagation off emits a flat unconditional call sequence — no guard methods, no dirty flags, every node fires every cycle.

Configurationns/eventDownstream callsFinal value
Stream clock, dirty on8.45102,500,00011551.2267
Stream clock, dirty off5.98205,000,00111551.2267
Default clock, dirty on17.45102,500,00011551.2267
Default clock, dirty off16.90205,000,00011551.2267

The execution change is narrow and explicit. Every externally observed value in this fixture is identical; what moves is how often downstream ran. Pure computation may produce the same result — but invocation counts roughly double, so counters, accumulators, logging, outbound calls and any other invocation-sensitive behaviour will not, and the observable output changes with them.

That 2.47 ns is fixture-specific. The detector arrests roughly half the cycles here; when every cycle propagates, dirty checks are pure overhead, while more frequent arrests or more expensive downstream work can repay them. In this deliberately cheap graph they had not broken even at an arrest rate of about 50%. The benchmark did not sweep arrest rate or downstream cost, so it does not locate a general break-even point. Dirty propagation is required here for behavioural correctness around non-idempotent nodes; it should not be sold as a universal throughput optimisation.

The floor — and what of it actually ships

Configurationns/eventEvents/sec
Hand-written components
Same unconditional work
3.51285,078,967
Shipped — everything off, generic entry point5.49182,225,705
Shipped — typed entry point5.51181,573,882
Experimental, not shipped — typed entry routed straight to dispatch
Hand-edited generated source
4.92203,334,689
Experimental, not shipped — plus the last auditor removed4.88204,737,629

The typed entry point buys nothing today — 5.51 against 5.49, because it still routes through the generic path, paying the type dispatch and re-entrancy machinery anyway.

A specialised mode could save ~0.59 ns, about 11% — it does not exist today. Routing a typed entry straight to dispatch was measured by hand-editing generated source. Adding it would mean giving up the generic re-entrancy and buffering path and committing the caller to a concrete event type: a mode someone would have to opt into, not a switch waiting to be flipped.

Removing the final auditor buys 0.04 ns. Not worth a switch.

What the audit log costs

Configurationns/eventBytes/event
No audit compiled in17.90.0
Audit compiled in, level NONE37.00.0
Event-stringification off, level ERROR120.80.0
Default, level ERROR
Nothing published
142.7184.0
Default, level INFO
10M records
441.4460.0

Two things worth knowing. An audit-enabled graph at a suppressing level can be entirely allocation-free — one boolean turns off per-event stringification of the triggering event, which is on by default and costs 184 B/event even when nothing is published. And merely compiling audit in costs ~2× at zero allocation, because the tracing call sites are emitted unconditionally and the level is checked inside each.

The audit level is a runtime setting, so records can be suppressed without a rebuild — but instrumentation is not removed by doing so. "Off in the hot path" is true of record production and false of instrumentation.

What this evidence does not support

  • "Generated orchestration is free." It is not. Against hand-inlined Java it is 2.68×; against hand-written equivalent guarded dispatch it is within 19%. The defensible sentence is within 19% of a hand-written implementation of the same dirty-propagation and guarding semantics, at no measured per-event allocation — a claim about dispatch cost, not about whether an application is correct.

  • Larger graphs. Ten nodes. Dispatch is straight-line so linear scaling is expected, but that is a prediction.

  • Multi-threaded or paced-arrival behaviour. Not measured.

  • Other machines. One machine; one process per configuration for the collector, audit-cost and floor tables, which carry no spread.

  • Not a one-command reproduction package. A script rebuilds and re-runs the throughput ladder from committed sources; the collector, audit-cost, floor and latency tables were transcribed from stdout and have no committed raw output.

Intent is authored. Consequences are derived.

This note prices the second half of that sentence: about 1.3 ns per event.

Experiment archive: this is a working repository, not a frozen snapshot of the evidence at publication. Files and results may move or change as the experiments and independent review continue. All arms asserted byte-identical on externally observable output before timing. The harness, generated sources and raw results for the headline table are held there. JDK 21.0.9 Corretto, macOS, single core, closed loop.