native-image: priority inliner leaves a hot dispatch chain out of line, defeating partial escape analysis (3.5x)
Summary
A generated event-dispatch chain is not inlined into its caller's hot loop. Because one link stays out of line, the receiver object is passed as an argument, escapes, and the ten objects it owns stop being scalar-replaced — a 3.5x throughput loss. -H:PriorityForceInline=<Class>.* recovers it fully.
Environment
- Oracle GraalVM 25.0.4+7.1 (
graal-25.3.4.1), macOS aarch64 native-image --no-fallback --gc=epsilon -R:MaxHeapSize=256m- PGO profile collected from an instrumented build of the same sources
Measured
200M events per run, output verified identical across all builds, medians of 3 runs:
| build | ns/event |
|---|---|
accurate PGO + -H:PriorityForceInline=<Class>.* |
1.57 |
| accurate PGO only | 5.56 |
PriorityForceInline only (no profile) |
6.53 |
| neither | 6.79 |
Both are needed together; neither alone is close.
Shape of the code
A generated class with a chain onEvent(T) -> processEvent(Object) -> onEventInternal(Object) -> handleEvent(T), where handleEvent calls ten small methods on ten final fields of the same object. The caller is:
static void run(long n) {
Processor p = new Processor(); // never escapes this method
Event e = new Event();
for (long i = 0; i < n; i++) p.onEvent(e.set(...));
out = p.result();
}When the whole chain inlines, the processor and its ten node objects are scalar-replaced and the loop runs at 1.57 ns/event — slightly faster than an equivalent hand-written flat implementation (1.55). When any link is left out of line, p is passed as a receiver to a non-inlined callee, escapes, and none of it is dissolved.
Minimal reproducer
Two byte-identical static methods, each constructing the object and running the loop, dispatched from main by an explicit switch:
- With both methods hot and profiled: both compile at 1.57.
- With only one present: it compiles at 5.55.
Same source, same flags, same profile procedure. Deterministic across three fresh rebuilds (spread ±0.15 ns). The single-method shape is what a real application has.
Ruled out by direct measurement
Each tested individually; all left the single-method case at 5.5–5.7:
- profile volume (5x longer instrumented run) and profile count (1, 2, 3 merged profiles)
-H:IPEAMaxForce=10and=50-H:BaseTargetSpending=2000-H:CallGraphSizeLimit=20000,-H:CallGraphCompilerNodeLimit=200000-H:+ContextAwareInlining-H:EscapeAnalysisIterations=8-H:MaximumInliningSize=1000and=3000,-H:TrivialInliningSize=100- allocation-site count (adding a second, cold allocation site)
- moving the hot loop to its own class
if/elseversusswitchdispatch inmain- build nondeterminism (three fresh rebuilds, stable)
Only PriorityForceInline changes the outcome.
Possibly a separate issue: individual method patterns have no effect
Naming the chain's methods explicitly does not work, while the whole-class wildcard does:
| pattern | ns/event | image size |
|---|---|---|
Class.* |
1.57 | 9706 KB |
Class.onEvent,Class.processEvent,Class.onEventInternal,Class.handleEvent |
5.55 | 9706 KB |
Class.handleEvent |
5.58 | 9706 KB |
| no directive | 5.56 | 9706 KB |
The image size is identical in all four cases. I have not established whether the comma-separated form is being parsed as intended for this option.
Question
Is there a supported way to get the cost model to inline a chain like this without PriorityForceInline? The workaround works and we now emit the directive automatically alongside the generated class, but it requires every user of such a generator to know the flag exists, and the failure mode is silent — a correct program that is 3.5x slower with no diagnostic.
Source: oracle/graal