#14387·graal

native-image: priority inliner leaves a hot dispatch chain out of line, defeating partial escape analysis (3.5x)

Author: gregv12Created Sep 7, 2026Updated Sep 10, 2026
Labelsbugnative-image

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:

java
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=10 and =50
  • -H:BaseTargetSpending=2000
  • -H:CallGraphSizeLimit=20000, -H:CallGraphCompilerNodeLimit=200000
  • -H:+ContextAwareInlining
  • -H:EscapeAnalysisIterations=8
  • -H:MaximumInliningSize=1000 and =3000, -H:TrivialInliningSize=100
  • allocation-site count (adding a second, cold allocation site)
  • moving the hot loop to its own class
  • if/else versus switch dispatch in main
  • 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.