Skip to content

Reference: Performance profiles

A profile is a named bundle of settings. This page states the whole bundle, what each setting costs, and what you give up — so the trade is visible before you pick one.

Every figure is from the performance benchmark: a four-node market-data graph, one thread, Apple M4, ns/event.

Setting a profile in your build

A profile is a generation-time decision: it changes the source the compiler emits, so it is set where you define the graph, not on the running processor. If you arrived from the AOT quickstart, that quickstart's Fluxtion.compileAot(node1, node2) form takes node instances and gives you nowhere to put one. Use the config-builder overload instead — the lambda receives the EventProcessorConfig every snippet on this page calls config:

var dataFlow = Fluxtion.compileAot(c -> {
    c.performanceProfile(PerformanceProfile.LOWEST_LATENCY);   // the profile
    c.addNode(new MyNode(...), "myNode");                      // then the graph, as usual
});

For a real build you normally want to name the output rather than have it derived from the enclosing class and method:

Fluxtion.compileAot(c -> {
    c.performanceProfile(PerformanceProfile.LOW_LATENCY_AUDIT)
     .addLowLatencyEventLog(LogLevel.INFO, AuditRecordFormat.BINARY);
    c.addNode(new MyNode(...), "myNode");
}, "com.example.trading", "PricingProcessor");

Order matters when you override. The profile sets a bundle; a per-setting call after it wins. So c.performanceProfile(LOWEST_LATENCY); c.setSupportReentrancy(true); keeps everything the profile did except re-entrancy. Reverse those two lines and the profile overwrites your override.

Not every profile suits every graph, and the build says so

LOWEST_LATENCY turns off node-name lookup, which the functional DSL requires. Applying it to a DSL graph is refused at build time, naming the flag (setSupportNodeNameLookup(false)) and a node it affects — rather than failing later as a NullPointerException inside the runtime. If you get that refusal, either build the graph imperatively or use LOW_LATENCY_AUDIT, which keeps the lookup.

Because the profile is baked into the emitted source, a committed generated processor carries the profile it was generated under. Changing profile means regenerating; there is no runtime switch.

The four profiles

config.performanceProfile(EventProcessorConfig.PerformanceProfile.LOWEST_LATENCY);
capability DEFAULT AUDITED LOW_LATENCY_AUDIT LOWEST_LATENCY
Audit logavailable, still needs enabling
Binary record (AuditRecordFormat.BINARY)
Per-node method tracing
Event toString() in each record
Thread name in each record
Clock — a system clock read per event
endTime — a SECOND clock read per audited event
Which clock STRATEGY that read uses
Node registration — supplies each node its EventLogger
Runtime node-name map (getNodeById)
Dirty filtering — conditional propagation
Buffer-and-trigger
Subscriptions
Re-entrancy
Void triggers

✅ on · ❌ off · ✋ your call, never the profile's · — not applicable

No profile turns the audit log on for you — including DEFAULT and AUDITED

The audit row is ✋, not ✅, and the distinction is easy to get wrong. A profile decides whether auditing is possible; you still have to ask for it:

config.performanceProfile(AUDITED).addAuditedEventLog(LogLevel.INFO);
config.performanceProfile(LOW_LATENCY_AUDIT).addLowLatencyEventLog(LogLevel.INFO, BINARY);

Generate under DEFAULT without that second call and the processor contains no EventLogManager at all — asking for one throws. If you are wondering why your audit log is empty, this is the first thing to check.

What each profile costs

Same graph, same work, same result, both toolchains:

configuration audit JIT ns native ns
hand-written Java, no framework no 6.4 4.3
LOWEST_LATENCY no 9.4 4.5
no configuration at all no 15.1 14.7
LOW_LATENCY_AUDIT + BINARY yes 20.4 18.2
LOW_LATENCY_AUDIT + TEXT yes 42.3 50.6
AUDITED + tracing yes 112.3 200.8

Provenance

Measured 2026-09-09: JIT is OpenJDK 25.0.2, native is GraalVM 25.0.4 with PGO. The two audited rows were taken under that day's development defaults — a projected clock and no endTime — and the shipped LOW_LATENCY_AUDIT keeps the accurate clock, so expect them a few nanoseconds higher on what ships. The measurement taken on the shipped defaults and the current GraalVM (25.3.4, whose new inliner is worth 38% on dispatch) is on the performance page: 7.5 ns unaudited, 23.8 audited on the six-node quote engine.

Configuring nothing costs 3.3× the tuned configuration on native. Nothing about the default is wrong; all of it is optional, and none of it announces itself. Per event it is a clock read, three dirty-flag stores, three guard checks and three resets on this four-node graph:

// no configuration                            // LOWEST_LATENCY
clock.eventReceived(typedEvent);               // (empty method)
isDirty_mid = mid.newPriceLadder(arg0);        mid.newPriceLadder(arg0);
if (guardCheck_skew())   isDirty_skew   = ;   skew.calculateSkewedLadder();
if (guardCheck_levels()) isDirty_levels = ;   levels.calculateLevelsForLadder();
if (guardCheck_publish())                 ;   publisher.publishPriceLadder();
clock.processingComplete();                    // (empty method)
isDirty_ = false;  ×3                         // nothing to reset

Choosing a toolchain: it depends on the profile

This is the least obvious result on this page.

  • Lean path → native AOT, decisively. 4.5 ns against 9.4, with a spread across independent builds of 0.1 ns. Native is also closer to hand-written Java than the JIT manages — 4% against 47%.
  • Text-heavy audit path → JIT, decisively. At AUDITED the JIT wins by 1.8× (112.3 against 200.8), and on a text record by 1.2×. Closed-world compilation cannot speculate its way through string formatting the way HotSpot does.
  • Binary audit → native. 18.2 against 20.4.

So the toolchain follows the profile. A native image is not uniformly faster, and choosing it for a tracing-heavy configuration makes things worse.

The settings that matter most, individually

1 · Code shape beats every flag — construct the processor inside the hot method

Worth 4.3× under native AOT and nothing at all on JIT. If the processor escapes the method that drives it, escape analysis cannot scalar-replace the node graph and every field access becomes a real load. Nothing warns you.

2 · -H:-SpawnIsolates on the native build — gone in GraalVM 25.3

Worth ~24 ns/event on an audited path when it was measured (round 63, GraalVM 25.0). It is a build flag, not a config setting, and it was the single largest win found in a round of work that also rewrote three data structures. GraalVM 25.3 refuses it — "isolate support can no longer be disabled" — and the quote-engine measurement of 2026-09-12, built without it on 25.3.4, shows no such penalty: 13.2 ns unaudited against 12.7 recorded with the flag on 25.0.4, and 23.1 audited, level with the JITs. Treat the −24 ns as a fact about 25.0, not a flag to look for.

3 · Node-name lookup

setSupportNodeNameLookup(false) — the largest single config cost. It is what LOWEST_LATENCY turns off that the audit profiles cannot: node registration is how each node receives its EventLogger, so turning it off turns the audit log off. A profile that did both once benchmarked extremely well by recording nothing.

4 · The clock

Every profile except LOWEST_LATENCY reads a system clock per event, and an audited record reads one again for endTime.

No profile selects a clock strategy — that row is ✋, your call. Not because a build input could never carry one: a generated processor holds its own Clock, and build inputs routinely produce runtime setup. It is that no such policy exists today, and that changing the graph's clock changes what every time-windowed node believes the time is — so it is not a dial a latency profile should quietly turn. Choosing LOW_LATENCY_AUDIT reads the default clock unless you say otherwise.

Giving the audit record its own clock does not help, and was tried

The obvious shortcut — a private fast clock for the record, leaving the graph's alone — makes the path slower. ClockFactory registers the graph Clock as an auditor unconditionally and Clock.eventReceived reads the wall clock on every event, so a private clock is a second read, not a cheaper one. It also misses shareReading, which the generated processor calls on the graph clock so a re-entrant wave reuses one timestamp, and it never receives ClockStrategyEvent, so a replay that makes graph time deterministic would leave audit timestamps on machine time.

Avoiding the graph clock read entirely — when no node actually uses time — needs the compiler to know that, and is a generator change rather than a runtime one.

The default is System::currentTimeMillis — epoch milliseconds, read fresh every time. Two cheaper strategies exist and both are opt-in, because both trade away something the default promises:

clock source cost/call resolution tracks wall-clock corrections?
System::currentTimeMillis — the default 12.9 ns 1 ms yes
ClockStrategy.fastEpochMillisClock() 8.0 ns 1 ms no
ClockStrategy.nanoEpochClock() 8.0 ns 1 ns no

The two fast strategies sample the wall clock once, at construction, and advance from System.nanoTime() thereafter. That makes them monotonic — they will not step backwards over an NTP correction, which currentTimeMillis can — but it also means they never step forwards over one. A correction from NTP, an operator or a VM resume is invisible to them, and a long-lived process keeps stamping a pre-correction timeline with drift that is never reconciled.

That matters because the runtime is itself an absolute-time consumer: Clock.eventReceived stores the reading and every audit record emits it as logTime. Good for durations, wrong for timestamps anyone correlates with something outside the JVM — so the accurate clock is the default and the fast ones are chosen deliberately.

// cheaper, same unit, will not track a wall-clock correction.
// Worth pairing with LOW_LATENCY_AUDIT, which reads the clock on every event.
processor.onEvent(ClockStrategy.registerClockEvent(ClockStrategy.fastEpochMillisClock()));

Decide it against the log's readers, not the latency alone: under a projected clock the logTime on every record drifts from wall-clock for the life of the process. If nothing correlates those timestamps with anything outside the JVM, take the saving.

nanoEpochClock() changes the unit, and time-windowed nodes name theirs

getWallClockTime() returns nanoseconds under it where the default returns milliseconds, and FixedRateTrigger.atMillis() means milliseconds by construction. Installing it on a graph with a tumbling or sliding window stops the window rolling — silently, with the arithmetic out by a factor of a million. Use it when sub-millisecond timestamps matter and the graph has no time-windowed nodes. A binary audit file then needs its writer constructed with BinaryLogFile.TIME_UNIT_EPOCH_NANOS, and an Event's own eventTime stays in the producer's milliseconds — see Binary audit logging.

endTime is on by default, as it has been in every release, and LOW_LATENCY_AUDIT elects not to collect it. It is the second clock read on an audited event path and it exists only for endTime - logTime: the duration evidence this profile elects not to collect. Measured 2026-09-12 on the six-node quote engine, GraalVM 25.3.4 JIT, accurate default clock: the audited event is 37.5 ns with it and 23.9 ns without — the second reading is a third of the event. The profile keeps the accurate clock (a projected clock changes what logTime means). On the wire an unrecorded endTime is 0, which the binary format defines as not recorded and the analyser reads as absent.

Precedence, and when each setting takes effect. EventLogManager.recordEndTime governs the records the manager builds — at generation time it is written into the processor as a field assignment, and at runtime the setter is live, changing the record the manager currently holds. A record you supply through EventLogControlEvent keeps its own setRecordEndTime: an explicit record is an explicit choice, and a supplied record's default is true. Later setting wins, on whichever object you set it.

// BUILD TIME - the profile's default, and how to put endTime back before generation
config.performanceProfile(LOW_LATENCY_AUDIT)
      .addLowLatencyEventLog(LogLevel.INFO, AuditRecordFormat.BINARY);      // endTime off
((EventLogManager) config.getAuditorMap().get(EventLogManager.NODE_NAME))
        .recordEndTime(true);                                                // back on, generated that way

// RUNTIME (Java targets) - on a generated processor, any time after construction; the setter is live
// and applies to the record the manager holds. getAuditorById infers its return type from the target.
EventLogManager manager = processor.getAuditorById(EventLogManager.NODE_NAME);
manager.recordEndTime(true);

// RUNTIME - a record you supply keeps its own setting, under any profile
BinaryLogRecord mine = new BinaryLogRecord(clock);
mine.setRecordEndTime(false);
processor.onEvent(new EventLogControlEvent(mine));

Taking both savings — the fast clock and no endTime — is worth 14.2 ns on JIT and 11.2 on native on the audited binary arm, measured when both were briefly defaults during development. The two ship differently. endTime off is the profile's default: LOW_LATENCY_AUDIT and addLowLatencyEventLog take no second clock reading, and the examples above put it back. The projected clock stays opt-in under every profile because it changes the meaning of logTime (a nanoTime projection, never re-anchored); LOW_LATENCY_AUDIT keeps the accurate default clock, and the fast clock is a separate choice.

The runtime precedence — a supplied record keeps its own setting, the live setter governs the record the manager holds — is Java runtime behaviour. The C++ target has no runtime record swap and no live setter: its recordEndTime is fixed at generation from the captured field, and false under the profile.

A nanosecond timestamp costs more to format in a text record

Nineteen decimal digits instead of thirteen. A binary record stores the raw long and pays nothing for the extra digits; a text record formats them on the event path. It is one more reason the binary record is the right choice for a latency profile.

5 · Dirty filtering and re-entrancy carry semantic consequences

LOW_LATENCY_AUDIT deliberately keeps both, because an audit profile must not change what the graph computes. LOWEST_LATENCY gives up dirty filtering: an @OnTrigger method then runs whenever the wave reaches it, not only when a parent is dirty. Invisible for pure recomputation; not invisible for a node that accumulates or has side effects.

setSupportReentrancy(false) is never set by a profile. It is the one setting that can turn a working graph into an IllegalStateException — re-entrant dispatch stops being queued and throws instead. Measured at approximately zero benefit. Set it only if your graph provably never re-enters.

6 · Void triggers

@OnTrigger(failBuildIfMissingBooleanReturn = false) lives on your node classes, so no profile can set it for you.

No audit trail, lowest latency:

config.performanceProfile(LOWEST_LATENCY);
// build native with --gc=epsilon and a PGO profile (-H:-SpawnIsolates is refused from GraalVM 25.3)
// construct the processor inside the method that drives it

Audit trail, lowest latency:

config.performanceProfile(LOW_LATENCY_AUDIT)
      .addLowLatencyEventLog(LogLevel.INFO, AuditRecordFormat.BINARY);
processor.onEvent(new ClockStrategy.ClockStrategyEvent(ClockStrategy.nanoEpochClock()));

…and note that BINARY needs a BinaryLogWriter sink installed after construction and before the first event, or the first record refuses — the analyser and the command-line reader can. See Binary audit logging.

Developing, and you do not yet know what you are looking for: AUDITED with tracing. It is 30× the cost of the tuned configuration and it tells you which node did what, which is worth far more than nanoseconds while you are still finding out.