Skip to content

perf(java): MA's dispatch frames return from each arm, so C2 will inline them - #406

Merged
mario4tier merged 3 commits into
devfrom
perf/dispatch-peek-arm-return
Sep 8, 2026
Merged

perf(java): MA's dispatch frames return from each arm, so C2 will inline them#406
mario4tier merged 3 commits into
devfrom
perf/dispatch-peek-arm-return

Conversation

@mario4tier

Copy link
Copy Markdown
Member

MaStream.peek and maStepImpl are switches over MAType, sitting under every handle that holds an MA sub-handle — APO, BBANDS, MACDEXT, MAVP, PPO, PVO, STOCH, STOCHF, and KDJ/STOCHRSI through those. Both accumulated into a local and fell through to a shared tail; both were over HotSpot C2's default FreqInlineSize of 325 bytes, so C2 refused to inline them.

Each arm now returns directly, as C already does.

before after
MaStream.peek 345 B 286
maStepImpl 329 B 305

Measured

JDK 21, default flags, one function per process, min of 12 passes of 2M calls, bar varied per call (a peek is pure w.r.t. its handle, so a constant bar lets C2 hoist the body out of the loop):

peek before after
MA(SMA) 3.009–3.034 ns/op 0.936–0.961
STOCH 8.887–9.108 7.165–7.743
KDJ 11.002–11.359 9.558–9.652
STOCHRSI 10.543–10.826 9.302–9.869

MaStream.update 5.30–6.11 → 3.56–4.93 ns/op, ten runs per arm in both orders, ranges disjoint. -XX:+PrintInlining ties both to the mechanism.

Do not measure this by walking several functions in one JVM — earlier blocks pollute later ones and the ratios come out wrong in both directions.

Java only, deliberately

C already emits arm-returns. Rust is unaffected (identical after mem2reg). C# was implemented and measured, then dropped: IL 397 → 358 against RyuJIT's ~100-byte threshold flips no inlining decision, and three runs per arm moved no row outside its own spread.

The gate

This expires on a schedule — a new MAType costs peek 16 bytes and the step frame 20, against 39 and 20 of headroom, so peek survives two more types and the step one. The enum is live (HMA, RMA, ZLEMA all in 2026), and crossing the budget changes no output and fails no test.

scripts/check_java_inline_budget.py runs in java-compiles, where the classes are already on disk: ~2.5s, one javap call, the only thing in CI reading generated Java as bytecode. Proved both ways it could read green — against the pre-change classes it fails at 345/329, and a moved signature errors rather than skipping silently.

325 is C2's default, not a law. When the gate fires, the fix is to split the switch: common MATypes in the inlinable frame, the rest in a second method free to grow.

Correctness unchanged: single-output frames only, since a multi-output peek's caller sink is written after the frame.

https://claude.ai/code/session_01N7HZcFbUwe3tB9XpKxkFPK

…ne it

The frame accumulated into a cur_* local and fell through to a shared tail.
Each arm now returns, and the tail goes with the local: 345 bytes of bytecode
down to 286, under C2's default FreqInlineSize of 325. Over that budget the
frame is refused ("hot method too big" under -XX:+PrintInlining), and it sits
under every handle holding an MA sub-handle -- APO, BBANDS, MACDEXT, MAVP, PPO,
PVO, STOCH, STOCHF, and KDJ and STOCHRSI through those.

JDK 21, default flags, ONE function per process, min of 12 passes of 2M calls,
three processes per arm; bar varied per call, because a peek is pure w.r.t. its
handle and a constant one lets C2 hoist the whole body out of the loop:

  MA(SMA)   3.009-3.034 -> 0.936-0.961 ns/op
  STOCH     8.887-9.108 -> 7.165-7.743
  KDJ      11.002-11.359 -> 9.558-9.652
  STOCHRSI 10.543-10.826 -> 9.302-9.869

Do not measure this by walking several functions in one JVM: the earlier blocks
pollute the later ones and the ratios come out wrong in both directions.

Only single-output frames may do this. A multi-output peek returns void and its
caller's sink is written after the frame, so an early exit would skip the write
silently -- PeekFrame carries that as `terminal`, since javac also rejects the
tail outright once every arm returns (JLS 14.22).

C already emits arm-returns. Rust is unaffected: the two shapes are identical
after mem2reg. C# was implemented and measured, then dropped -- IL 397 to 358
against RyuJIT's ~100-byte threshold flips no inlining decision, and three runs
per arm moved no row outside its own spread.

Claude-Session: https://claude.ai/code/session_01N7HZcFbUwe3tB9XpKxkFPK
…udget

Found reviewing the peek change. maStepImpl is the switch behind every MA
update, and at 329 bytes it was over C2's 325-byte FreqInlineSize by four. The
switch is the whole method body and the method is void, so each arm returns
instead of jumping to the end: 329 -> 305.

MA(SMA).update 5.30-6.11 -> 3.56-4.93 ns/op, ten runs per arm, both orders,
ranges disjoint. -XX:+PrintInlining ties it to the mechanism: "inline (hot)" at
305, "hot method too big" at 329.

Measure this one function per process with a fresh handle each pass. update
mutates handle state, so a reused handle makes successive passes measure
different work; and a harness that walks several functions in one JVM lets the
earlier blocks pollute the later ones -- read that way this change looks like a
20% REGRESSION, which is the reverse of what it is.

Claude-Session: https://claude.ai/code/session_01N7HZcFbUwe3tB9XpKxkFPK
The two arm-return commits are worth ~a third of the per-bar time of every
handle holding an MA sub-handle, and they expire on a schedule: each new MAType
grows MaStream.peek by 16 bytes and maStepImpl by 20, against 39 and 20 bytes
of headroom. So peek survives two more types and the step frame one -- and the
enum is live, with HMA, RMA and ZLEMA all landing in 2026.

Nothing held that. Crossing the budget changes no output, fails no test, and
costs a third of the speed silently, which is the whole reason to spend a gate
here rather than leave a comment.

It runs in java-compiles, where the classes are already on disk, and is the only
thing in CI that reads generated Java as bytecode. ~2.5s, one javap call.

Both ways it could read green are proved rather than assumed: pointed at the
pre-change classes it fails with peek at 345 and the step at 329, and a moved
signature is an error rather than a silent skip -- it measured nothing, so it
must not report success.

325 is C2's default, not a law: it moves between JDK versions and a deployment
can override it. The message says the frame left the budget the shape was
measured against, and names the fix -- an N-way switch cannot stay under a fixed
budget as N grows, so the next MAType splits it, common types here and the rest
in a second method free to grow.

Claude-Session: https://claude.ai/code/session_01N7HZcFbUwe3tB9XpKxkFPK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant