Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pr-codegen-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ jobs:
exit 1
fi

# Free here: the classes are already on disk, and nothing else in CI ever
# looks at generated Java as BYTECODE. Crossing C2's inline budget changes
# no output and fails no test, so without this it lands silently.
- name: MA's dispatch frames stay inside C2's inline budget
shell: bash
run: python3 scripts/check_java_inline_budget.py /tmp/ta-java-main

# Hand-written, and the generator preserves them — so an API change lands
# here only if someone carries it. Nothing else on this gate reads them.
- name: Compile the hand-written Java suites against it
Expand Down
108 changes: 108 additions & 0 deletions scripts/check_java_inline_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""MA's two dispatch frames must stay inside HotSpot C2's inlining budget.

`MaStream.peek` and `Core.maStepImpl` are switches over `MAType`, and they sit
under every handle that holds an MA sub-handle -- APO, BBANDS, MACDEXT, MAVP,
PPO, PVO, STOCH, STOCHF, and KDJ and STOCHRSI through those. C2 refuses to
inline a hot method whose bytecode exceeds `FreqInlineSize`, so crossing that
budget costs those callers roughly a third of their per-bar time, measured. It
costs nothing visible: no test fails, no output changes.

Each new MAType grows peek by 16 bytes and the step frame by 20 (the arm body
plus a tableswitch entry), so this expires on a schedule -- and the enum is
live: HMA, RMA and ZLEMA all arrived in 2026.

325 is C2's DEFAULT, not a law. It moves between JDK versions and any
deployment can override it. This gate says the frames left the budget the
arm-return shape was measured against, not that the code is wrong.

Usage: check_java_inline_budget.py <classes-dir>
"""

import re
import subprocess
import sys

# `-XX:FreqInlineSize`, the HotSpot default for a hot call site.
BUDGET = 325

FRAMES = [
("Core$MaStream", re.compile(r"^\s+public double peek\(double\);")),
("Core", re.compile(r"^\s+void maStepImpl\(io\.github\.talib\.Core\$MaStream, double\);")),
]

# A method's last instruction is always a 1-byte return or throw, so the final
# offset plus one is the Code attribute's length -- the number C2 compares.
# Asserted rather than assumed: a multi-byte last instruction would silently
# undercount and this gate would read green while the frame was over.
LAST_OPS = {"return", "ireturn", "lreturn", "freturn", "dreturn", "areturn", "athrow"}


def die(msg: str):
print("::error::%s" % msg)
sys.exit(1)


def disassemble(classes: str) -> str:
"""One javap call for both frames -- two would be two JVM startups."""
names = ["io.github.talib." + c for c, _ in FRAMES]
try:
return subprocess.run(["javap", "-p", "-c", "-cp", classes] + names,
capture_output=True, text=True, check=True).stdout
except (subprocess.CalledProcessError, FileNotFoundError) as e:
die("javap could not read %s from %s: %s" % (", ".join(names), classes, e))


def code_length(out: str, cls: str, sig: re.Pattern) -> int:
lines = out.splitlines()
start = next((i for i, l in enumerate(lines) if sig.match(l)), None)
if start is None:
die("%s: no method matching %s -- the signature moved, so this gate "
"measured NOTHING. Fix the pattern in this script rather than "
"deleting the check." % (cls, sig.pattern))

last_off, last_op = None, None
for l in lines[start + 1:]:
m = re.match(r"\s+(\d+): (\S+)", l)
if m:
last_off, last_op = int(m.group(1)), m.group(2)
elif last_off is not None and not l.strip():
break
if last_off is None:
die("%s: matched the signature but found no bytecode under it." % cls)
if last_op not in LAST_OPS:
die("%s: last instruction is %r, not a 1-byte return/throw, so "
"offset+1 is not the code length. Teach this script the real "
"length before trusting it." % (cls, last_op))
return last_off + 1


def main():
if len(sys.argv) != 2:
die("usage: check_java_inline_budget.py <classes-dir>")
classes = sys.argv[1]

out = disassemble(classes)
over = []
for cls, sig in FRAMES:
n = code_length(out, cls, sig)
name = "%s.%s" % (cls, "peek" if "peek" in sig.pattern else "maStepImpl")
print("%-24s %3d bytes (budget %d, %+d)" % (name, n, BUDGET, n - BUDGET))
if n > BUDGET:
over.append((name, n))

if over:
die("%s over C2's %d-byte FreqInlineSize: %s. Every caller holding an MA "
"sub-handle just lost inlining on that frame, worth ~a third of its "
"per-bar time. An N-way switch cannot stay under a fixed budget as N "
"grows, so the fix is to split it: keep the common MATypes in this "
"frame and delegate the rest to a second method that may grow freely. "
"Emitter: build_dispatch_peek_frame / emit_dispatch in "
"ta_codegen/generator/src/backends/java_stream.rs."
% ("Frame" if len(over) == 1 else "Frames", BUDGET,
", ".join("%s at %d" % (n, b) for n, b in over)))
print("Both MA dispatch frames are inside the %d-byte budget." % BUDGET)


if __name__ == "__main__":
main()
116 changes: 82 additions & 34 deletions ta_codegen/generator/src/backends/java_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,8 @@ fn emit_loop_shape(
func, model, &fields, &step_settings, stream_fma, enums, registry, helpers, counter, 9,
&BTreeSet::new(),
);
emit_handle_class(o, func, &fields, &SubMembers::none(), frame.as_deref());
let frame = frame.map(PeekFrame::falls_through);
emit_handle_class(o, func, &fields, &SubMembers::none(), frame.as_ref());
emit_step(o, func, model, &step_settings, stream_fma, enums, registry, helpers, counter);
emit_open_body(
o, func, model, body, &fields, &step_settings, stream_fma, enums, registry,
Expand Down Expand Up @@ -628,14 +629,29 @@ impl SubMembers {
}
}

/// A rendered peek frame and whether it returns on every path. A terminal
/// frame owns its exits, so the tail read of `cur_*` must NOT follow it:
/// javac rejects the unreachable statement outright (JLS 14.22), and a
/// multi-output frame would skip the caller's sink write instead.
struct PeekFrame {
body: String,
terminal: bool,
}

impl PeekFrame {
fn falls_through(body: String) -> Self {
Self { body, terminal: false }
}
}

/// Emit the nested handle class. `subs` holds the tier-owned members
/// (sub-handle copies); loop tier passes [`SubMembers::none`].
fn emit_handle_class(
o: &mut String,
func: &FuncDef,
fields: &[Field],
subs: &SubMembers,
frame: Option<&str>,
frame: Option<&PeekFrame>,
) {
emit_handle_class_with_members(o, func, fields, subs, "", frame);
}
Expand All @@ -648,7 +664,7 @@ fn emit_handle_class_with_members(
fields: &[Field],
subs: &SubMembers,
extra_members: &str,
frame: Option<&str>,
frame: Option<&PeekFrame>,
) {
let class = stream_class_name(func);
let base = base_name(func);
Expand Down Expand Up @@ -918,7 +934,7 @@ fn finite_bar_check(func: &FuncDef, indent: &str, what: &str) -> String {
}


fn emit_update_peek_value_copy(o: &mut String, func: &FuncDef, frame: Option<&str>) {
fn emit_update_peek_value_copy(o: &mut String, func: &FuncDef, frame: Option<&PeekFrame>) {
emit_update_method(o, func);
emit_peek_method(o, func, frame);
emit_value_method(o, func);
Expand Down Expand Up @@ -988,7 +1004,7 @@ fn emit_update_method(o: &mut String, func: &FuncDef) {
}

// --- peek ------------------------------------------------------------------------
fn emit_peek_method(o: &mut String, func: &FuncDef, frame: Option<&str>) {
fn emit_peek_method(o: &mut String, func: &FuncDef, frame: Option<&PeekFrame>) {
let class = stream_class_name(func);
let multi = has_value_class(func);
let vt = if multi {
Expand All @@ -1010,8 +1026,8 @@ fn emit_peek_method(o: &mut String, func: &FuncDef, frame: Option<&str>) {
// rather than off the output count: the outputs go to the caller's own sink
// now, so the count says nothing about what a peek allocates.
let allocates = frame.is_some_and(|f| {
f.contains(".clone()")
|| f.lines().any(|l| l.contains(" = new ") && l.trim_end().ends_with("Out();"))
f.body.contains(".clone()")
|| f.body.lines().any(|l| l.contains(" = new ") && l.trim_end().ends_with("Out();"))
});
let cost = if allocates {
"It copies no buffer: the frame runs against this handle, reading its\n\
Expand Down Expand Up @@ -1040,10 +1056,12 @@ fn emit_peek_method(o: &mut String, func: &FuncDef, frame: Option<&str>) {
// run any of it.
o.push_str(&require_sink(func, " ", "peek"));
o.push_str(&finite_bar_check(func, " ", "peek"));
let body = frame.expect("every tier emits a peek frame");
let frame = frame.expect("every tier emits a peek frame");
let _ = writeln!(o, " {class} sp = this;");
o.push_str(body);
if multi {
o.push_str(&frame.body);
if frame.terminal {
assert!(!multi, "a terminal peek frame would skip the caller's sink write");
} else if multi {
o.push_str(&write_out_stmts(func, "out", "", " "));
} else {
let _ = writeln!(o, " return {};", fresh_value_expr_local(func));
Expand Down Expand Up @@ -1273,21 +1291,34 @@ fn build_dispatch_peek_frame(
enums: &HashMap<String, EnumDef>,
registry: &Registry,
helpers: &HelperRegistry,
) -> String {
) -> PeekFrame {
// Arms return straight out of the switch rather than accumulating, to keep
// the frame under C2's default `FreqInlineSize` (325 bytes of bytecode):
// over it, every caller holding an MA sub-handle loses inlining. The
// accumulating shape measured 345, this one 286, and each further MAType
// arm costs 16.
let terminal = func.outputs.len() == 1;
let mut f = String::new();
for out in &func.outputs {
let jty = out_java_type(func, &out.name);
let zero = if jty == "int" { "0" } else { "0.0" };
let _ = writeln!(f, " {jty} cur_{} = {zero};", out.name);
if !terminal {
for out in &func.outputs {
let jty = out_java_type(func, &out.name);
let zero = if jty == "int" { "0" } else { "0.0" };
let _ = writeln!(f, " {jty} cur_{} = {zero};", out.name);
}
}
if let Some(idp) = &dp.identity {
let cond = params_on_state(func, &idp.condition);
let cond = render_predicate(&cond, ctx, registry, helpers);
let _ = writeln!(f, " if( {cond} ) {{");
for (out, inp) in &idp.pairs {
let _ = writeln!(f, " cur_{out} = {inp};");
if terminal {
let (_, inp) = &idp.pairs[0];
let _ = writeln!(f, " return {inp};");
} else {
for (out, inp) in &idp.pairs {
let _ = writeln!(f, " cur_{out} = {inp};");
}
let _ = writeln!(f, " return {};", fresh_value_expr_local(func));
}
let _ = writeln!(f, " return {};", fresh_value_expr_local(func));
let _ = writeln!(f, " }}");
}
let _ = writeln!(f, " switch( sp.{} )", dp.param);
Expand All @@ -1297,15 +1328,18 @@ fn build_dispatch_peek_frame(
let cls = callee_stream_class(registry, &arm.callee);
let ocls = callee_out_class(registry, &arm.callee);
let _ = writeln!(f, " case {label}: {{");
let mut returned = false;
if arm.out_map.len() == 1 {
let streaming::OutSlot::Forward(k) = arm.out_map[0] else {
panic!("single-output arm cannot discard its only slot")
};
let _ = writeln!(
f,
" cur_{} = (({cls}) sp.sub).peek({bar_args});",
outputs[k]
);
let call = format!("(({cls}) sp.sub).peek({bar_args})");
if terminal {
let _ = writeln!(f, " return {call};");
returned = true;
} else {
let _ = writeln!(f, " cur_{} = {call};", outputs[k]);
}
} else {
let _ = writeln!(
f,
Expand All @@ -1314,16 +1348,24 @@ fn build_dispatch_peek_frame(
);
for (i, slot) in arm.out_map.iter().enumerate() {
if let streaming::OutSlot::Forward(k) = slot {
let _ = writeln!(
f,
" cur_{} = subValue.{};",
outputs[*k],
callee_value_field(registry, &arm.callee, i)
);
let field = callee_value_field(registry, &arm.callee, i);
if terminal {
let _ = writeln!(f, " return subValue.{field};");
returned = true;
} else {
let _ = writeln!(f, " cur_{} = subValue.{field};", outputs[*k]);
}
}
}
}
let _ = writeln!(f, " break;");
if terminal {
// Without a forwarded slot the arm emits no exit at all and falls
// into the next case, which casts this arm's sub-handle to the next
// arm's class.
assert!(returned, "dispatch arm {label} forwards no output");
} else {
let _ = writeln!(f, " break;");
}
let _ = writeln!(f, " }}");
}
let _ = writeln!(f, " default:");
Expand All @@ -1332,7 +1374,7 @@ fn build_dispatch_peek_frame(
" throw new IllegalStateException(\"unreachable: open rejects arms without a sub-stream\");"
);
let _ = writeln!(f, " }}");
f
PeekFrame { body: f, terminal }
}

/// [`peek_frame_arm_named`] for the tiers whose transition uses the ordinary
Expand Down Expand Up @@ -2766,7 +2808,8 @@ fn emit_dual_mode(
f
})
};
emit_handle_class(o, func, &fields, &SubMembers::none(), dual_frame.as_deref());
let dual_frame = dual_frame.map(PeekFrame::falls_through);
emit_handle_class(o, func, &fields, &SubMembers::none(), dual_frame.as_ref());

// --- step: one function, the mode re-derived from the stored param ------
emit_step_sig(o, func);
Expand Down Expand Up @@ -3001,7 +3044,10 @@ fn emit_dispatch(
}
}
}
let _ = writeln!(o, " break;");
// `return`, not `break`: the switch is the whole method body, so this
// costs a byte where the jump to the end cost three, and the step frame
// is 4 bytes over the same 325-byte budget the peek frame is kept under.
let _ = writeln!(o, " return;");
let _ = writeln!(o, " }}");
}
let _ = writeln!(o, " default:");
Expand Down Expand Up @@ -3251,6 +3297,7 @@ fn emit_period_bank(
let _ = writeln!(bank_frame, " }}");
let _ = writeln!(bank_frame, " int slot = cp - sp.{min};");
let _ = writeln!(bank_frame, " double cur_{out} = sp.bank[slot].peek({price});");
let bank_frame = PeekFrame::falls_through(bank_frame);
emit_handle_class_with_members(o, func, &fields, &subs, &extra_members, Some(&bank_frame));

// --- step: advance ALL slots, output the clamped-period slot ------------
Expand Down Expand Up @@ -4111,7 +4158,8 @@ fn emit_composed(
helpers, counter, &fields, true,
)
};
emit_handle_class_with_members(o, func, &fields, &subs, &extra_members, frame.as_deref());
let frame = frame.map(PeekFrame::falls_through);
emit_handle_class_with_members(o, func, &fields, &subs, &extra_members, frame.as_ref());

emit_composed_step(
o, func, cp, &step_settings, stream_fma, registry, &inputs, &outputs, enums, helpers,
Expand Down
Loading