Tuning for lowest latency¶
How to configure a Fluxtion processor for the lowest achievable event cost, what each choice is worth, and the traps that silently cost 3–5x.
This is the tuning guide; Performance results is the benchmark
That page measures a realistic graph end to end with JMH, including application logic. This one is about dispatch cost and configuration — how to get the generated processor itself out of the way. The two are complementary and their headline numbers are not comparable: they measure different things on different graphs.
Evidence. Every figure here is measured — ~700 runs in round 58 and a verification round in round 59, medians over 200M-event runs with output verified identical across arms before any timing was believed.
Provenance: measured, but not currently reproducible by you
Every figure on this page was measured, and each names the shape it was measured in. What is not available is the workspace to re-run them: round 58's build did not survive, and the runnable example repository this page should point at does not exist yet (see Reproducing this). A review on 2026-09-11 could inspect the committed notes and scripts but could not execute the claimed inputs, so the headline native/PGO figures are plausible, not independently verified.
Treat this as a record of measurements taken, not as a claim you can check today. Where a number would change a decision, re-measure on your own shape. The paragraph below stands as the rule the figures were collected under.
Read this first: every figure names its shape¶
The same processor source has measured anywhere from 1.4 to 29 ns per event depending on configuration and deployment shape. While round 58 was written up, four successive drafts carried a wrong headline figure — each a true measurement of a different shape, quoted without naming it.
So: a figure without its shape is not a result. Every number below carries one.
At a glance¶
A generated Fluxtion processor dispatches at about 1.67 ns/event — 600 million events per second on
one core — and does so whether the graph has one event type or three. Against hand-written flat Java
that is 8.6% on a single-type straight line and 22.5% on a three-type branching graph: the processor's
cost is flat, the hand-rolled floor is what moves. See Multiple event types below before quoting
either percentage. The nodes can come from a vendor jar the generator
only saw as bytecode, with getNodeById, lookupInstanceName, re-entrancy, subscriptions and
buffering all live.
The repeated measurement. Five independent profile-and-build cycles of the same source — fresh PGO
collection each time, performanceProfile(LOWEST_LATENCY), the generated inlining directive, output
verified identical, none allowed to exit early:
| cycle | generated | hand-rolled | generated | gap |
|---|---|---|---|---|
| 1 | 1.6383 ns | 1.4828 ns | 610 M/s | 10.5% |
| 2 | 1.6487 | 1.5184 | 607 M/s | 8.6% |
| 3 | 1.6818 | 1.5505 | 595 M/s | 8.5% |
| 4 | 1.6799 | 1.5581 | 595 M/s | 7.8% |
| 5 | 1.6806 | 1.5622 | 595 M/s | 7.6% |
| mean | 1.666 | 1.534 | 600 M/s | 8.6% |
5 of 5 landed in the fast mode, spread 0.044 ns. One cycle came in at 10.5%, so "within 10%" is the average and not a bound — quote 8.6% mean, 7.6–10.5% observed.
Best ever measured, on a cooler machine in an earlier session, and reached in five independent harnesses:
| ns/event | events/sec | |
|---|---|---|
| generated Fluxtion processor (native + PGO + inlining directive) | 1.57 | 637M |
| hand-rolled flat Java, same arithmetic | 1.55 | 645M |
hand-optimised C++ -O3 -march=native |
1.57 | 636M |
| the same processor, misconfigured | 5.6 – 29 | 34M – 179M |
Do not plan against the 1.3% gap in that second table
It is a real measurement of a cooler machine, and the generated and hand-rolled figures in it come
from the same build, so the comparison is sound. But the repeated measurement above puts the gap
at 8.6%, not 1.3%, and a reader who expects 1.3% will measure 8 and think something is wrong.
The absolute figures move with machine state — the hand-rolled control drifted 1.4828 → 1.5622
across those five cycles as the machine warmed — so generated − hand is the quantity that
travels: 0.118 – 0.156 ns, mean 0.131, all day, across every shape measured.
Multiple event types and branching paths — the gap depends on your alternative, not on the processor¶
The figures above are a single event type down a straight line. A second graph in the kit has three event types taking three different paths, which is what a real system looks like:
| event | nodes fired |
|---|---|
MarketTick |
tickIn → mid, ewma, spread, notional, vol → shared tail |
TradeEvent |
tradeIn → position → shared tail |
LimitEvent |
limitIn → limit, and nothing downstream |
| shared tail | exposure, charge, buffer, limit |
Correctness is gated before anything is timed. app.CorrectnessMulti drives both arms through
200,000 interleaved events and compares every published field after every event, bit-exact — not
the three values the throughput harness prints, and not only at the end, because an ordering error that
cancels by the last event is still an ordering error.
(The hand-rolled arm's firing order was read off the generated source, not reasoned about. Two of the
orderings are not what a person writes by hand — the tick path fires mid, ewma, spread, notional,
vol, and the shared tail puts limit last — and the generator factors the tail shared by two paths
into one method rather than emitting it twice.)
Measured, mixed stream (~75% ticks, 25% trades, 0.1% control), outputs identical:
| JIT (3 reps) | native + PGO (3 cycles, 3 of 3 landed) | |
|---|---|---|
| generated | 5.541 | 1.681 — 595 M/s |
| hand-rolled | 2.680 | 1.372 |
| gap | 2.07× | 22.5% |
Read that against the single-type figures and the useful fact appears: the generated processor costs
the same either way. 1.666 ns on one event type, 1.681 on three — the dispatch cost is flat. What
moved is the floor: hand-rolled dropped from 1.534 to 1.372, because a quarter of the events now take
a much shorter path and a hand-written if chain gets all of that saving.
So the percentage gap is a property of your alternative, not of the generated code:
| shape | generated | hand-rolled | gap |
|---|---|---|---|
| one event type, straight line | 1.666 | 1.534 | 8.6% |
| three types, branching paths | 1.681 | 1.372 | 22.5% |
Quote 8.6% only for the shape it was measured on. The transferable number is the absolute: ~1.67 ns/event, ~595–600M events/sec on one core, stable across both shapes.
Against a hand-written library, on a realistic graph¶
The figures above compare against hand-written flat code — one class, primitive fields, the type known at every call site. That is the right control for "how much does the framework cost", and the wrong one for "what would I otherwise build". A real alternative is a library: wiring decided by configuration, nodes reached through interfaces, dispatch resolved from data.
14 nodes, three event types, dependency edges, injected strategies, the same node classes in both arms — only the wiring differs. Outputs identical:
| JIT | native + PGO | native speed-up | |
|---|---|---|---|
| generated | 6.22 | 2.12 | 2.93× |
| hand-written library | 18.84 | 20.06 | 0.94× — slower |
| ratio | 3.0× | 9.5× |
Read the last column. Ahead-of-time compilation makes the generated processor nearly 3× faster and makes the library 6% slower. A library's wiring is data the compiler cannot see, so there is nothing for AOT to specialise — it pays the closed world's costs and collects none of its benefits.
The generated advantage is a function of node weight, and it cuts both ways. On light nodes (a few flops, which is what most graphs are) the whole graph inlines and the ratio is 9.5×. On heavy nodes — measured with 4×4 matrix multiplies — neither arm's bodies inline, the advantage is capped by the compiler's inlining budget, and at 50 such nodes it inverts to 1.3× against the generated processor. Measure your own graph; the answer depends on what your nodes do.
What 600 M/s is and is not. It is single-threaded dispatch cost on one core: one event type, a 10-node graph, epsilon GC, back-to-back calls in a bounded loop, no I/O, no allocation, no contention. It is the cost of the dispatch machinery, not a system throughput figure.
Measured across five JVMs and two native builds, from the reusable kit
(tools/bench/latency-kit) — 10-node graph, nodes from a separately compiled jar, 200M events,
output verified identical, full configuration applied:
| runtime | generated | hand-rolled | ratio |
|---|---|---|---|
| Temurin 17.0.14 | 5.60 | 3.57 | 1.6× |
| Temurin 21.0.5 | 5.58 | 3.69 | 1.5× |
| Corretto 21.0.9 | 5.58 | 3.82 | 1.5× |
| OpenJDK 24 | 5.58 | 3.77 | 1.5× |
| GraalVM 25.0.4 (Graal JIT) | 5.47 | 2.18 | 2.5× |
| GraalVM 25.0.4 native-image, no PGO | 6.54 | 2.54 | 2.6× |
| GraalVM 25.0.4 native-image + PGO | 1.62 | 1.57 | 1.03× |
The last row is the point: with the full configuration, generated dispatch is within 3% of hand-rolled flat Java. Every JIT lands at 5.5–5.6 regardless of vendor.
The native + PGO figure is a property of the PROFILE, not of the source
Rebuild from a freshly collected profile and you get either ~1.6 ns or ~5.5 ns, nothing in between. Rebuild from the same profile and you get the same answer every time — four rebuilds from a landing profile gave 1.60/1.66/1.68/1.67, three from a missing one gave 5.71/5.63/5.61. The compiler is deterministic; profile collection is not.
So collect until one lands, then keep the profile and rebuild from it. It is a build input, like
any other. tools/bench/land-native.py does both halves. The JIT numbers need none of this: they
are deterministic on all five JVMs. Related:
oracle/graal#14387.
Native-image is only faster if you configure it — otherwise it is SLOWER than a JIT
In the sweep above, native without PGO (6.54 ns) is slower than every JIT measured, including plain Temurin. Native-image beats a JIT only when an accurate PGO profile and the inlining directive and the configuration below are all present. If you cannot collect a representative profile, a JIT is the safer choice — Graal JIT at 5.47 ns is the best unconditional number on this graph, and no JIT vendor differs by more than 2%.
Read those two tables together. 1.6 ns needs native-image and an accurate profile and the inlining directive and the configuration below — and then it still has to land. Miss any one of them and you are at 5.5–6.5 every time, still correct, just 3–4× slower, with no diagnostic. Build it and measure it.
Every step on this page is worth 2× or more, and getting one wrong is silent — the program stays correct and simply runs slower.
Is getting there a deterministic process now?¶
The procedure is. A single build is not. That distinction is the whole of this page's hard-won content, and it is worth stating in one place.
Deterministic — you can rely on these:
| evidence | |
|---|---|
| A given binary reproduces its own figure, for ever | v6 reads 5.34/5.46/5.43 and v7 reads 1.43/1.44/1.44 across repeated runs, under a shifted stack and a shifted heap alike |
| A given profile reproduces its own mode, on every rebuild | four rebuilds from one profile: 1.5987 / 1.6552 / 1.6804 / 1.6653 — and a missing profile reproduces just as faithfully: 5.7102 / 5.6310 / 5.6136 |
| The configuration decides the floor | omit the inlining directive and you get 5.5–6.5 every time, not sometimes |
| The JIT figures | 5.1 ns on all five JVMs, no vendor differing by more than 2% |
Not deterministic — do not rely on this:
| evidence | |
|---|---|
| That any fresh profile collection lands | 5 of 5 in one run, 11 of 13 in another, and 19 consecutive misses in a third — all on unmodified inputs |
So the process is: make the lottery someone else's problem by only entering it once.
- Configure — the checklist below. This decides the floor, and it is fully deterministic.
- Collect a profile, build, measure.
tools/bench/land-native.pydoes the loop. - When it lands, keep the profile, not just the binary. It is a build input.
- Rebuild from that profile from then on —
--profile— and the result comes back every time. - Gate the build on the measurement, because a build that missed is 3.5× slower and silent.
Steps 1, 3, 4 and 5 are deterministic. Step 2 is the only die roll, you only roll it when the graph changes, and the harness rolls it for you until it lands.
What that buys, measured: ~1.67 ns/event, 600M events/sec on one core, 8.6% off hand-written flat Java — from a processor generated out of a jar the compiler only saw as bytecode.
Checklist for maximal performance¶
Work down it. Each item is silent when omitted — the program stays correct and simply runs slower — so the only way to know you have them all is to check.
Build
-
config.performanceProfile(LOWEST_LATENCY)— one line, and it sets the three below for you - ↳ framework auditors dropped (no
Clockreading the system clock per event) - ↳
setSupportDirtyFiltering(false)(no dirty flags, no guards) - ↳
setSupportNodeNameLookup(false)(no node registration — the single largest cost) - ↳
setSupportBufferAndTrigger(false)+setSupportSubscriptions(false)(added to the profile 2026-09-07 — they shrink the generated event path and, measured, are worth nothing; they are there because the code is smaller and neither can change a result) -
setSupportReentrancy(false)— yours to set, and the profile will not do it for you. It is the one setting here that can break a working graph: re-entrant dispatch stops being queued and throws instead. Measured at the same time and worth nothing either. Set it only if your graph provably never re-enters - void triggers on every node —
@OnTrigger(failBuildIfMissingBooleanReturn = false)and the same on@OnEventHandler. The profile cannot set this for you; it lives on your classes. - If you need the audit log instead:
performanceProfile(AUDITED)+addAuditedEventLog(LogLevel.INFO)— keeps the log, drops the 208 bytes/event
Runtime shape
- processor constructed inside the method that runs the event loop, and never escapes it
- nothing between the constructor and the loop — no timing call, no logging, no registration
- if you kept the
Clockauditor: supply aClockStrategybefore entering that method
Native image (skip all of this if you deploy on a JIT — none of it applies)
-
--pgo=<profile>from a run that exercises every path you deploy -
-H:PriorityForceInline=<YourProcessor>.*— emitted for you inMETA-INF/native-image/…/native-image.propertieswhengenerateReachabilityMetadatais on - whole-class wildcard, not a curated method list — naming methods individually does not work
- never reuse a profile across image kinds, from a different entry point, or across a rebuild of
the instrumented image — a stale profile measured 8.0 ns, worse than no profile at all, and the
build reports
PGO: user-providedwithout a warning
Verify — do not assume
- run
tools/bench/latency-kit/run.shagainst your own graph - native only: check the build LANDED —
tools/bench/land-native.pycollects until it does; a build that missed is 3.5× slower and silent - native only: commit the profile that landed and rebuild from it (
--profile). It is a build input, and it is the only thing that makes the result reproducible - compare arms with
tools/bench/dispatch-bench.py, which refuses to report until the arms produce identical output - if a number surprises you, check this list before concluding anything about the compiler — four times in round 59 a missing setting or a harness defect looked exactly like one
The whole configuration¶
Every config. line below is set on the EventProcessorConfig you get from the config-builder form of
Fluxtion.compileAot — see
Setting a profile in your build if you are
coming from the quickstart's compileAot(node1, node2) form, which has nowhere to put one.
// ---- build ------------------------------------------------------------------
@OnTrigger(failBuildIfMissingBooleanReturn = false) // void trigger: no dirty flag, no guard
@OnEventHandler(failBuildIfMissingBooleanReturn = false)
config.performanceProfile(LOWEST_LATENCY); // auditors off, no dirty flags,
// no node registration
// or, if the audit log is the point:
// config.performanceProfile(AUDITED).addAuditedEventLog(LogLevel.INFO);
// ---- runtime ----------------------------------------------------------------
processor.onEvent(ClockStrategy.registerClockEvent(() -> myStreamTime)); // else it reads the
// system clock per event
// ---- the event loop: construct the processor INSIDE the method that loops ----
static void run(long n) {
MyProcessor p = new MyProcessor(); // must not escape this method
MyEvent e = new MyEvent();
for (long i = 0; i < n; i++) p.onEvent(e.set(...));
}
native-image --pgo=app.iprof \
'-H:PriorityForceInline=com.your.pkg.MyProcessor.*' \
-cp ... MyApp
What each step is worth, measured, on a real generated processor:
| step | cost of omitting it | where |
|---|---|---|
| force the dispatch chain to inline | 3.5× (1.57 → 5.56) | native only |
setSupportNodeNameLookup(false) |
3.5× (1.62 → 5.61) | native only |
supply a ClockStrategy (or drop the auditors) |
5.8× (5.03 → 29.08) | both |
| accurate PGO profile | 4.0× (1.62 → 6.54) | native only |
| non-escaping processor | 2–3× | native only |
| void triggers + no dirty filtering | large; changes semantics | both |
printEventToString(false) when auditing |
208 bytes/event, rules out epsilon GC | both |
None of these is a micro-optimisation. The first four are each worth more than everything else on this page combined. Only the inlining directive is emitted automatically by the generator (§ship the directive); the rest you must ask for, and omitting any one is silent.
The baseline configuration, in one place¶
This is the configuration to start from for best performance. Five items. Two of them change behaviour and are your decision; three are free.
// ---- 1. SEMANTIC CHOICES — these change what the graph does ------------------------------
// 1a NO DIRTY FLAGS: every node fires on every event, unconditionally
@OnTrigger(failBuildIfMissingBooleanReturn = false) // void trigger, no dirty flag, no guard
@OnEventHandler(failBuildIfMissingBooleanReturn = false)
config.setSupportDirtyFiltering(false); // and no dirty-flag machinery at all
// 1b IF YOU AUDIT: turn off event stringification and thread name.
// This is BASELINE, not an optimisation to consider later: it is the difference between
// zero allocation and 208 bytes/event, and it removes about a third of the audit cost.
config.addEventAudit(LogLevel.INFO, false, false); // printEventToString, printThreadName
// ---- 2. FREE — no capability lost ---------------------------------------------------------
// 2a SUPPLY A CLOCK: otherwise the Clock auditor reads the system clock on EVERY event
processor.onEvent(ClockStrategy.registerClockEvent(() -> myStreamTime));
// 2b DEPLOYMENT SHAPE: construct the processor inside the method that runs the event loop,
// and time from the caller. Nothing between the constructor and the loop. (§below)
// 2c BUILD WITH AN ACCURATE PGO PROFILE, collected from what you actually deploy. (§below)
// 2d FORCE THE DISPATCH CHAIN TO INLINE — without this you lose 3.5x: (§below)
// native-image -H:PriorityForceInline='com.your.pkg.YourProcessor.*'
// 2e NO NODE REGISTRATION — the single largest cost, and you must ask for it:
// otherwise every node is published into the auditor's HashMaps and none can be
// scalar-replaced. Lookup still works: the generator emits getInstanceById and
// lookupInstanceName as CODE, so nothing is lost. (§6)
config.setSupportNodeNameLookup(false);
// 2f NO AUDITORS, if you do not need the audit log: the Clock auditor alone reads the
// system clock on every event, and the three framework auditors together consume the
// escape-analysis budget the node graph needs.
// config.getAuditorMap().keySet().removeAll(config.getFrameworkAuditorNames());
// ---- NOT needed — all measured free. Set only if you don't want the capability -------------
// config.setSupportReentrancy(false); // wrapper free, guard free
// config.setSupportBufferAndTrigger(false); // free
// config.setSupportSubscriptions(false); // free
Measured end state: ~1.6 ns/event unaudited, ~550 ns fully traced-and-audited, both zero-allocation, both from generated code.
1 · No dirty flags¶
setSupportDirtyFiltering(false) plus void triggers. Removes the guards. A void trigger returns
no boolean, so there is no dirty flag to store, no guard to test, and no dirty-flag maps on the
processor.
What you give up: conditional propagation. Every node fires on every event. If your graph relies on a node declining to propagate, this changes results, not just speed. Take it when the graph is a pipeline that recomputes everything anyway.
2 · No auditors doing real work¶
Every generated processor carries the Clock, NodeNameLookup and ServiceRegistry auditors, and
calls them on every event. Five of those six calls are free — round 58 measured inherited no-op
default methods as indistinguishable from no call at all on every runtime, native included, so do
not try to hand-optimise them away.
The sixth is not free. Clock.init() sets wallClock = System::currentTimeMillis, and
Clock.eventReceived calls it on every event:
| ns/event | events/sec | |
|---|---|---|
| default clock (reads the system clock per event) | 29.08 | 34M |
supplied ClockStrategy |
5.03 | 199M |
The clock is 83% of the default cost on a JIT — measured on a real generated processor (round 59), and round 58 measured the same on all seven runtimes it tested (19.01 → 7.71 on Graal JIT). This is the lever you lose by doing nothing, and it was undocumented until now.
Supply a stream time whenever your events carry their own. If they do not, you are performing an ambient clock read inside the event path, which is also what makes a run non-reproducible — the performance argument and the determinism argument coincide.
3 · Optimised re-entrancy¶
setSupportReentrancy(false). Worth −26% on native, −7% on a JIT.
processEvent runs on every event and, with support on, tests a flag, may queue a re-entrant event,
and drains a callback queue. When no node in the graph can raise a re-entrant event that queue is
provably always empty and all of it is dead code. With the flag off the generated processor also
dispatches the typed entry directly — onEvent(MarketTick) calls handleEvent(event) instead of
widening to Object and recovering the type with an instanceof chain.
A guard is retained and it throws. Build-time detection cannot be complete — a node can reach the dispatcher through a service or reflectively — so a re-entrant event fails loudly rather than vanishing.
4 · No buffering · 5 · No subscriptions¶
setSupportBufferAndTrigger(false) removes the buffering branch.
setSupportSubscriptions(false) stops the constructor publishing the processor to the subscription
manager — which matters for more than one reason, see below.
The profile sets the first; the second is yours
Until 2026-09-07 performanceProfile(LOWEST_LATENCY) set three things and left both of these on,
so the generated processEvent still carried a buffer guard on every event. It now also sets
setSupportBufferAndTrigger(false) and setSupportSubscriptions(false).
Measured, that is worth nothing on this graph — three interleaved JIT reps read 5.177 before and 5.124 after, overlapping; a landed native build reads inside the usual band. They are set because the generated code is smaller and because neither can change a result: you either use the capability or you do not.
setSupportReentrancy(false) is deliberately NOT in the profile. It is the one of the three
that can break a working graph — re-entrant dispatch stops being queued and throws
IllegalStateException instead — and build-time detection cannot be complete, because a node can
reach the dispatcher through a service or reflectively. It was measured at the same time and
bought nothing either, so the profile does not spend that capability for you.
6 · Node-name lookup generated as code — the largest single cost, and it costs you nothing¶
Measured: 5.55 → 1.57 ns on a real generated processor, with lookup still working.
initialiseAuditor registers every node with each auditor, and NodeNameAuditor stores them in
two HashMaps. Every node object is then published into a live heap structure, so none can be
scalar-replaced and the whole graph materialises as real allocations.
It is invisible unless you build with PGO. Without a profile the processor measures ~5.5 ns either way — that configuration is already slow for other reasons — so a benchmark without PGO will tell you this is free. It is not.
You give up nothing. The generator knows every name and field at build time, so it emits the
mapping as a switch that reads a field on demand and stores no reference. getNodeById keeps working.
The switch never runs on the event path, so its cost is irrelevant — what matters is that it holds
nothing.
The one case still to pay for it: an auditor that consumes nodeRegistered — an audit log that
names its nodes — still receives every node and still publishes them. An audited processor does not
reach 1.57 by this route, and that is a trade worth making.
What is actually left on the event path — measured, not reasoned¶
Four shapes of the same graph, each a real build, each verified to produce identical output. The generated arm is what changes; the hand-rolled arm is the control.
| shape | JIT | native + PGO, landed |
|---|---|---|
| A as shipped | 5.141 | 1.54 – 1.68 |
| C + auditor off the event path (W15) | 5.155 | 1.6867 |
| D + service registry gone (post-W11) | 5.155 | 1.6900 |
| E + buffer, drain and the re-entrancy guard gone (emulation) | 4.893 | 1.6706 |
| F the emulation configured for real, re-entrancy off | 5.143 | 1.7176 (1 build) |
G what LOWEST_LATENCY now generates — the shipped shape |
5.124 | 1.638 – 1.682, mean 1.666, 5 of 5 fresh cycles |
| hand-rolled control | ~3.5 | ~1.53 – 1.56 |
E is an emulation and it does not measure what you think
E was hand-edited to remove the buffer guard, the callback drain and the re-entrancy guard, and
it reads 4.893 — a clean 5%, reproducible, non-overlapping. It is tempting to attribute that to
setSupportBufferAndTrigger(false) + setSupportReentrancy(false), and this page did for an
hour. It is wrong. The real configuration keeps a re-entrancy guard — it throws instead of
queueing — and that guard is where the cost sits. Configure it for real and you get row F/G:
5.124–5.143, i.e. nothing.
The lesson is the one this whole page keeps relearning: measure the artefact you will ship, not a hand-edit that stands in for it.
The shipped row, measured properly. Five independent profile-and-build cycles of G — fresh
collection each time, none allowed to exit early — gave 1.6383 / 1.6487 / 1.6818 / 1.6799 / 1.6806,
5 of 5 in the fast mode, spread 0.044 ns. The hand-rolled control drifted 1.4828 → 1.5622 across
the same five as the machine warmed, so the portable quantity is the difference: 0.118 – 0.156 ns,
mean 0.131. Quote generated − hand, not the absolute, if you are comparing across machines.
But 5 of 5 is a sample, not a guarantee — the same day produced 19 consecutive misses on a different shape with unmodified inputs. Keep the profile that landed (Why a build lands).
Read the two columns differently, because they are telling you different things.
On a landed native build, framework overhead is already gone. Generated sits 0.12–0.14 ns above
hand-rolled and nothing in this table moves it — not removing auditor calls, not removing the service
registry, not removing the guards. With an accurate profile and the inlining directive the processor is
scalar-replaced whole: processing and buffering become registers, their branches fold, and there is
nothing left for a source-level change to remove. This is the end of the road for this kind of
tuning, and it is why every remaining item on the M50 list should be justified by correctness,
determinism or generated-code clarity rather than by a promised ns.
On the JIT the guards are real, because the processor is a live object and those are real field loads and branches. That is the one measurable win here, it is ~5%, and the profile does not give it to you.
What the gap to hand-rolled is NOT. Two hypotheses tested and both dead: it is not auditor
callbacks (removing them changes nothing) and it is not the service registry's reflection —
Method.invoke fires when a service registers, typically once at startup, and serviceRegistry
does not appear anywhere in the generated dispatch block. Verified by reading the generated source,
not inferred.
Deployment shape — worth more than every flag combined¶
Construct the processor inside the method that drives the event loop.
| processor reached via | native |
|---|---|
| a local that never escapes the method driving the loop | 1.41–1.53 |
a static / static final field |
3.10 |
| an instance field of a statically-held object | 4.82 |
If the processor escapes, the compiler cannot dissolve the node objects. It escapes by being stored in a static, a registry, a factory, a thread pool, or a getter that anything calls — and the escape happens at construction, so reading the field into a local before the loop does not recover it.
On a JIT every shape measures about the same; this is an AOT consideration.
Which runtime you deploy on decides which advice applies¶
These are two different optimisation problems. Measured on identical classes, same machine:
| Graal JIT | native-image, no PGO | native-image + accurate PGO | |
|---|---|---|---|
| processor carrying no framework fields | 4.64 | 1.86 | 1.56 |
| processor carrying all seven | 4.74 | 6.20 | 1.58 |
| hand-rolled flat equivalent | 2.09 | 2.45 | 1.55 |
On a JIT, none of the structural tuning below matters. Every configuration lands at ~4.6 ns, because the JIT never dissolves the processor's object structure — so nothing you remove was being optimised away in the first place. Its floor for a ten-node graph is ~4.6 and no flag reaches past it.
On native-image the structure is everything, because AOT does dissolve it — reaching 1.86 ns, faster than hand-rolled flat code at 2.45. That is also what makes it fragile: see the cliff below.
Note the JIT beats AOT on the flat hand-rolled arm (2.09 vs 2.45). This is not "AOT is faster". It is specifically that only AOT removes the graph's object structure.
The escape-analysis cliff — native without PGO only¶
Without a profile, a native image's escape analysis has a finite budget, and past it the processor stops being dissolved. It is a cliff, not a gradient — nothing lands between 1.9 and 6.2 ns:
| processor carries | ns |
|---|---|
| nothing extra | 1.87 |
| any ONE framework field (each tested separately) | 1.86–1.88 |
callbackDispatcher + clock + nodeNameLookup |
1.87 |
callbackDispatcher + nodeNameLookup + subscriptionManager |
6.22 |
| all seven | 6.20 |
all seven minus subscriptionManager |
6.16 |
It is cumulative — no single field is the culprit, in either direction¶
Tested exhaustively, both ways round, all arms in one binary:
| ns | |
|---|---|
| adding any ONE of the seven to a bare processor | 1.86–1.88 — every one free |
| removing any ONE of the seven from the full set | 6.12–6.29 — every one useless |
| the full set | 6.20 |
| none of them | 1.87 |
So there is no expensive field and no cheap win. Adding one costs nothing; removing one saves nothing. What decides the outcome is the total size of the allocation graph the compiler must dissolve:
| framework object | objects it allocates |
|---|---|
ServiceRegistryNode |
6 — four HashMaps plus a lock |
SubscriptionManagerNode |
5 — an ArrayList and three HashMaps |
NodeNameAuditor |
3 — two HashMaps |
CallbackDispatcherImpl |
2 — an ArrayDeque |
Clock, ExportFunctionAuditEvent |
1 each |
Three fields can be fine or fatal depending which three: callbackDispatcher + clock + nodeNameLookup
stays at 1.87, while callbackDispatcher + nodeNameLookup + subscriptionManager falls to 6.22.
The practical consequence: you cannot shave your way back. You have to get under the budget. Elision has to be measured, not counted — and on an unprofiled native image it is close to all-or-nothing.
Why a build lands: the profile decides, and the compiler is deterministic¶
The PGO profile decides the mode. Hold it fixed and the result reproduces — in both directions, every time. Profile passed explicitly, no collection, the profile's checksum verified unchanged across each build:
| profile | four rebuilds from it | image size |
|---|---|---|
| the one that produced 1.60 | 1.60 / 1.66 / 1.68 / 1.67 | identical every time |
| the one that produced 5.70 | 5.71 / 5.63 / 5.61 | identical every time |
So there is no mystery in the compiler. Two builds from one profile make the same decisions; they are not byte-identical — the checksums differ, so layout or ordering is nondeterministic — but nothing that changes the outcome is.
What varies is the profile. Collecting one means running an instrumented binary, so a profile is a measurement, and measurements vary. Two collections from the same instrumented image, same workload, minutes apart:
| section | one profile | the other | contexts differing |
|---|---|---|---|
callCountProfiles |
7,988 | 7,887 | 1,190 |
conditionalProfiles |
6,160 | 6,046 | 1,194 |
The four hot methods match exactly, at 21,000,000 each. Over a thousand contexts around them do not — class initialisation, deoptimisation, GC and sampling land differently run to run, and one of those differences only has to sit on an inlining decision.
So keep the profile, not just the binary
A profile that lands is a reproducible input: commit it next to the source, rebuild from it,
get the result again. A binary that lands is one artifact that goes stale the moment your classes
change. Treat the .iprof files as build inputs under version control, exactly like the
native-image.properties that carries the inlining directive.
Two things that look like this, and are not
Nothing about your graph. Round 59 published, and this page carried for a day, an explanation that the generated processor's ten node objects sit at a size threshold while hand-rolled is one object and therefore always lands. Withdrawn — in a missing build the hand-rolled arm does not land either, and it has nothing to dissolve that the processor could have spoiled.
Nothing about your configuration. Round 59 also reported that dropping the last auditor changed the landing rate. Withdrawn — the same unmodified configuration measured 1.60/1.67/1.68 one hour and 5.58/5.70/5.71/5.73 the next, with a freshly collected profile each time. Neither sample was measuring the auditor.
The general lesson under both: a handful of builds cannot tell you the sign of a change unless you hold the profile fixed. Round 59 concluded that it could, twice, and was wrong both times.
Once built, the binary is fixed. A given image reproduces its own number run after run; padding the environment to shift the stack and varying the heap size to shift the heap change nothing. So a build that landed is a build you can ship.
The knobs that do not work¶
native-image --expert-options-all offers a set of options whose names promise exactly what is wanted.
Each was measured on a full fresh cycle, at 4–6× its default, against a floor of 5.6:
| flag | result |
|---|---|
-H:IPEAMaxForce · -H:IPEAVirtualEscapeBoostSingle |
no effect |
-H:TuneInlinerExploration |
no effect, and +530 KB of image |
-H:BaseTargetSpending · -H:InliningCoefficient family |
no effect |
-H:MaximumInliningSize · -H:SmallCompiledLowLevelGraphSize |
no effect |
-H:EscapeAnalysisIterations · -H:EscapeAnalysisLoopCutoff |
no effect |
-H:PriorityForceInline widened to the nodes, the framework, the loop's own class |
no effect |
-H:+InlineEverything |
no effect |
-H:NumberOfThreads=1 or =4 (hoping for a deterministic build) |
no effect, and still not byte-reproducible |
InlineEverything failing to move it is the informative row: whatever bails is not reachable by
turning inlining up.
-H:PriorityForceInline=<YourProcessor>.* is the only lever that works — it is worth 3.5× and it
is not optional. It is also not sufficient, which is what the rest of this section is about.
PGO — an accurate profile removes the cliff; a bad one is worse than none¶
For an AOT Fluxtion processor a bad profile is worse than no profile. This is not a caution, it is a measurement:
| arm | exe, no PGO | exe, PGO | shared lib, no PGO | shared lib, PGO |
|---|---|---|---|---|
| non-escaping processor | 1.53 | 1.64 | 1.41 | 6.28 |
processor in a static final field |
3.10 | 2.51 | 3.10 | 6.25 |
- An accurate profile makes the cliff disappear. With profiles collected from every arm and merged, the processor carrying all seven framework fields runs at 1.58 ns — the same as one carrying none (1.56), and the same as hand-rolled (1.55). The structural sensitivity above is a property of unprofiled AOT, not of AOT.
- A non-escaping processor reaches ~1.4–1.9 ns with no profile at all, so PGO is not required — but it is what makes the result robust to structure rather than dependent on it.
- A mismatched or INCOMPLETE profile is worse than no profile. This is the single biggest trap here, and it was measured three separate times: an executable's profile applied to a shared library (1.41 → 6.28), and twice a path that was in the image but not in the profile (1.83 → 6.19 and 1.59 → 7.20). In every case the result was worse than building with no profile at all, because GraalVM reads absent profile data as coldness and compiles that path for size — so the escape analysis that reaches 1.57 never runs.
Every code path you deploy must be exercised during profile collection.
Collect from a run that exercises what you actually ship, and never carry a profile across image kinds. If one entry point is slow and the others are fast, suspect the profile before the code. - Round 58 saw PGO make its fastest shape slightly worse (1.53 → 1.64) with a narrower profile. Both observations hold: the profile's accuracy is the variable, not PGO itself.
What you do NOT need to do¶
All proven, so effort does not go the wrong way.
- Interface separation between components is free — ~0.03 ns per call site, and zero indirect
branches with a single implementor, verified by counting
blrin the disassembly. Structure your components for clarity. (With three implementations reachable in one image it costs +115%: AOT devirtualises what is provably monomorphic and nothing more.) - Event-type dispatch is not a scaling risk — 2 to 16 event types cost +0.26 ns total.
- Empty auditor calls are already free. Do not hand-optimise them.
- Flattening the graph into one method is not needed — worth −1.8%, and it cannot be applied to components you do not own.
Force the dispatch chain to inline — the single most important native-image setting¶
Without it you lose 3.5×, and the loss is silent.
native-image -H:PriorityForceInline='com.your.pkg.YourProcessor.*' ...
Measured on a single-processor, single-event-loop application — the shape a real deployment has:
| build | ns/event |
|---|---|
accurate PGO + PriorityForceInline |
1.57 |
| accurate PGO only | 5.56 |
PriorityForceInline only |
6.53 |
| neither | 6.79 |
Both are required; neither alone is close.
Why¶
GraalVM's priority inliner decides, from its cost model, not to inline
onEvent → processEvent → onEventInternal → handleEvent into your loop. Any link left out of line
receives the processor as an argument, so the processor escapes and its node objects stop being
scalar-replaced. That is the entire 3.5×. Forcing the inline removes the decision.
It is genuinely unreliable without the flag — the same code measured 1.57 in a binary that happened to contain a second hot loop over the same processor, and 5.5 in one that did not. Do not rely on the inliner choosing correctly.
How repeatable it is¶
Every shape below rebuilt with its own instrumented image and its own freshly collected profile, output verified identical. These are the builds that landed — see Why a build lands above for what that qualification is doing here:
| program shape | without flag | with flag |
|---|---|---|
| single processor, single event loop | 5.55 | 1.57 |
| one loop + an unrelated hot loop | 5.58 | 1.57 |
| one loop, nothing else | 5.55 | 1.58 |
| loop in its own class | 5.60 | 1.57 |
the reusable kit, tools/bench/latency-kit |
6.54 | 1.62 |
The flag is necessary and it is not sufficient. With a freshly collected profile each time, the landing rate is not a stable number: 11 of 13 across one session, 3 and then a long run of misses in the next. What is stable is the profile — rebuild from a landing one and it lands again, four times out of four. See Why a build lands. Related: oracle/graal#14387, where two images built from identical classes with byte-identical hot methods measured 1.43 and 5.45.
So collect until one lands, then keep and reuse that profile. The table above is what a landing build looks like, not a guarantee that a fresh collection lands.
A missing configuration setting looks exactly like an unstable compiler
The kit measured 6.22 for a long time with the directive correctly applied, and that was
published here as evidence the directive was unreliable. It was not: the kit's generator was
missing two settings — the framework auditors were still registered, and
setSupportNodeNameLookup(false) was absent, so every node was still published into the auditor's
maps. Adding them took it 6.22 → 5.61 → 1.62.
Check the configuration before concluding anything about the compiler. Every item in The baseline configuration is load-bearing, and omitting one is silent.
Use the whole-class wildcard. Naming individual methods does NOT work.¶
The pattern is GraalVM's MethodFilter syntax, so it is tempting to force only the event-path methods.
Measured, that fails — and it fails silently, at full speed-loss:
| pattern | ns/event | image size |
|---|---|---|
YourProcessor.* |
1.57 | 9706 KB |
YourProcessor.onEvent,…processEvent,…onEventInternal,…handleEvent |
5.55 | 9706 KB |
YourProcessor.handleEvent |
5.58 | 9706 KB |
| no flag at all | 5.56 | 9706 KB |
The image is the same size either way, so there is nothing to gain by narrowing it and a 3.5× regression to lose. Use:
-H:PriorityForceInline='com.your.pkg.YourProcessor.*'
Adding the node classes as well (,com.your.nodes.*.*) is harmless but gains nothing — 1.56 against
1.55. The processor class alone, with the wildcard, is the setting. Quote it in a shell or * will
glob.
Better: ship the directive with the processor, so nobody has to know¶
native-image reads META-INF/native-image/**/native-image.properties from the classpath. A generated
processor can carry its own directive:
# META-INF/native-image/com.telamin.fluxtion/generated-processor/native-image.properties
Args = -H:PriorityForceInline=com.your.pkg.YourProcessor.*
Verified end to end: with only that resource on the classpath and no flag on the command line, the same application builds at 1.58 ns instead of 5.55.
The generator knows its own fully-qualified class name at build time, so it can emit this file itself — the same partial-evaluation move the rest of this page describes, applied to the compiler's own configuration. That is open work (M50), not something the generator does today.
Wiring it into a Maven build¶
Everything above is expressed as native-image flags. In a real project those come from
native-maven-plugin, and the PGO steps become two Maven profiles. Verified against the
plugin documentation;
latest release at the time of writing is 0.10.6.
Two things this project's own results change about the stock recipe, and they are the whole point of this section:
- Commit the profile. The stock workflow regenerates
default.iprofon every build, so every build rolls the dice again — collection is what varies, not the compiler (Why a build lands). A profile that landed is a build input. Put it in the repository and point--pgoat it. - Nothing needs adding for the inlining directive.
native-imagereadsMETA-INF/native-image/**/native-image.propertiesoff the classpath, and the generated processor carries its own. It shows up in the build log as- '-H:PriorityForceInline' (origin(s): 'META-INF/native-image/…')— check for that line.
The two profiles¶
<properties>
<native.maven.plugin.version>0.10.6</native.maven.plugin.version>
</properties>
<profiles>
<!-- 1. mvn -Pinstrumented package → target/app-instrumented -->
<profile>
<id>instrumented</id>
<build><plugins><plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions><execution>
<id>build-native</id><phase>package</phase>
<goals><goal>compile-no-fork</goal></goals>
</execution></executions>
<configuration>
<imageName>app-instrumented</imageName>
<buildArgs>
<buildArg>--pgo-instrument</buildArg>
<buildArg>--no-fallback</buildArg>
</buildArgs>
</configuration>
</plugin></plugins></build>
</profile>
<!-- 2. mvn -Pnative package → target/app, built from the COMMITTED profile -->
<profile>
<id>native</id>
<build><plugins><plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${native.maven.plugin.version}</version>
<extensions>true</extensions>
<executions><execution>
<id>build-native</id><phase>package</phase>
<goals><goal>compile-no-fork</goal></goals>
</execution></executions>
<configuration>
<imageName>app</imageName>
<buildArgs>
<!-- one option expression per buildArg; this is a path in the repo, not default.iprof -->
<buildArg>--pgo=${project.basedir}/src/pgo/app.iprof</buildArg>
<buildArg>--gc=epsilon</buildArg>
<buildArg>--no-fallback</buildArg>
</buildArgs>
</configuration>
</plugin></plugins></build>
</profile>
</profiles>
--gc=epsilon belongs only to a bounded-run benchmark or a service that genuinely allocates nothing;
drop it otherwise. Everything else is independent of the collector.
Producing the profile you commit¶
Run this when the graph changes — not on every build. The loop exists because a fresh collection lands only some of the time, while the profile it produces reproduces every time.
#!/usr/bin/env bash
# Collect a PGO profile until the resulting image is fast, then keep that profile.
set -euo pipefail
TARGET_NS=2.0 # what "landed" means for your workload
mvn -q -Pinstrumented package
for attempt in $(seq 1 10); do
# Exercise EVERY path you deploy. An unprofiled path is worse than no profile at all,
# and the app must EXIT cleanly - the profile is written when it stops.
./target/app-instrumented --your --representative --workload
mv default.iprof "src/pgo/candidate-$attempt.iprof"
mvn -q -Pnative package -Dpgo.profile="src/pgo/candidate-$attempt.iprof"
ns=$(./target/app --benchmark | awk '/ns_per_event/{print $2}')
echo "attempt $attempt: $ns ns"
if awk "BEGIN{exit !($ns <= $TARGET_NS)}"; then
cp "src/pgo/candidate-$attempt.iprof" src/pgo/app.iprof
echo "landed on attempt $attempt — commit src/pgo/app.iprof"
exit 0
fi
done
echo "no attempt landed in 10; the best candidate is still in src/pgo/" >&2
exit 1
Wire -Dpgo.profile to the --pgo= buildArg with a property, so the same POM serves the search and
the committed build.
Three ways this goes wrong silently
A profile from a rebuilt instrumented image. Profiles belong to the instrumented image that
produced them. Carried across a rebuild of that image, one measured 8.0 ns — worse than no
profile at all — and the build still reported PGO: user-provided with no warning. Rebuild the
instrumented image and you must recollect.
An instrumented run that did not exit. default.iprof is written when the application stops.
A kill -9 on a long-running service leaves you with no profile, or a stale one from last time.
A workload that misses a path. An unprofiled path is compiled worse than with no profile at all. If you deploy two entry points, exercise both.
Gate the build, do not trust it¶
The difference between a landing build and a missing one is 3.5×, and it is invisible — same image
size, same log, no warning. Whatever your benchmark is, run it in the same job that produces the binary
and fail on a regression. For this project's shape that is
tools/bench/land-native.py; for an application it is your own benchmark plus a threshold, which is
what the script above does.
Honest numbers¶
Measured on macOS/aarch64, Oracle GraalVM 25.0.4, output verified identical on every arm.
| shape | JIT | native (no PGO) |
|---|---|---|
| generated, default clock, all support on | 29.08 | 16.07 |
generated + ClockStrategy |
5.03 | 8.92 |
| generated, full baseline config above | 5.16 | 7.03 |
| hand-rolled flat equivalent | 2.05 | 2.46 |
A note on provenance. Round 58's published figures of 1.41–1.55 ns were measured on
BaseProcessor, a hand-written stand-in, not on generated code. That gap is closed: with the
configuration above the generator itself produces 1.57 ns — on a build that lands — matching that
control (1.58) and hand-rolled flat code (1.55). See How repeatable it is: in a build that does not
land, the hand-rolled control does not land either.
Quote the shape, not the best number in the table.
Reproducing this¶
"Nothing here should be taken on trust" is the intent, and is not yet true for a reader outside this
project — the harness below is real and its refusals are real, but the inputs it ran on are not
published. Until the example project exists, a reader can check the METHOD and not the RESULT.
tools/bench/dispatch-bench.py refuses to report unless both
arms ran in one binary, the runtime kind is single and recorded, every arm emits identical check
values before any timing is believed, and no arm falls below an elimination floor — a probe measuring
0.0000 ns is a deleted loop, not a result, and that happened twice in round 58.
Example project: TO BE NAMED. This page should point at a runnable repository containing the graph, the two arms and the build scripts, so a reader reproduces rather than believes. That repository does not exist yet and its home is an owner decision — round 58's own workspace did not survive, which is precisely the argument for creating it.