perf(java): MA's dispatch frames return from each arm, so C2 will inline them - #406
Merged
Conversation
…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
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
MaStream.peekandmaStepImplare switches overMAType, 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 defaultFreqInlineSizeof 325 bytes, so C2 refused to inline them.Each arm now returns directly, as C already does.
MaStream.peekmaStepImplMeasured
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):
MaStream.update5.30–6.11 → 3.56–4.93 ns/op, ten runs per arm in both orders, ranges disjoint.-XX:+PrintInliningties 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.pyruns injava-compiles, where the classes are already on disk: ~2.5s, onejavapcall, 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