Skip to content

Commit ba597a0

Browse files
olwangclaude
andcommitted
feat(reg-vm): OSR×J2 inline closures with Float captures (Pending #2 broadening)
A captured f64 is bit-reinterpreted from its i64 capture slot (not integer- converted), reusing the native f64<->i64 bitcast convention, so a closure capturing Floats inlines inside an OSR loop. Int/Bool captures unchanged; non-scalar captures still bail. OSR differential byte-identical on/off (float parity exact); 10.3x faster on osr_float_closure_loop. Positive + negative tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82849eb commit ba597a0

4 files changed

Lines changed: 220 additions & 17 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// OSR × J2 FLOAT-CAPTURE kernel (Pending #2 broadening). A higher-order function
2+
// `apply_loop` takes a closure PARAMETER `g: read Fn(Float) -> Float` and calls it
3+
// in a hot loop. The closure CAPTURES a FLOAT (`scale`), so the inline must
4+
// materialize that f64 capture into the loop body by BIT-REINTERPRETING its i64
5+
// capture slot (the `closure_capture` helper returns `f64::to_bits` as i64; the
6+
// inlined body's Float-class capture register bit-casts it back to f64 — NOT an
7+
// integer→float conversion). `apply_loop` is also I/O-tangled (`Log.write` around
8+
// the loop in the same, once-called function), so the whole function is native-
9+
// INELIGIBLE — only OSR can run the loop natively, and only by inlining the
10+
// capturing closure into it (otherwise the per-iteration `CallClosure` keeps the
11+
// loop off the native subset).
12+
//
13+
// Run OSR off (`--mode jit-native`) vs OSR on (`--mode jit-native-osr`, or the
14+
// default auto-trigger); the OSR median should beat the off median. The closure is
15+
// monomorphic at the call site (one callee, one scalar Float capture), the
16+
// precondition for the OSR × J2 inline.
17+
18+
fn bench_size(default: Int) -> Int {
19+
let raw = Args.get_or_default(index: 0, default: read String.from_int(value: default))
20+
match String.parse_int(value: read raw) {
21+
Some(value) => {
22+
return value
23+
}
24+
None => {
25+
return default
26+
}
27+
}
28+
}
29+
30+
fn apply_loop(g: read Fn(Float) -> Float, limit: Int, seed: Float) -> Float {
31+
Log.write(message: read "osr-begin")
32+
let mut i = 0
33+
let mut total = seed
34+
let mut x = 0.0
35+
while i < limit {
36+
total = total + g(read x)
37+
x = x + 1.0
38+
i = i + 1
39+
}
40+
Log.write(message: read String.from_float(value: total))
41+
return total
42+
}
43+
44+
fn main() -> Unit {
45+
let limit = bench_size(default: 2000000)
46+
let scale = 1.5
47+
let g = fn(value) captures(read scale) effects(pure) {
48+
return value * scale + scale
49+
}
50+
let result = apply_loop(g: read g, limit: limit, seed: 0.0)
51+
Log.write(message: read String.from_float(value: result))
52+
return Unit
53+
}
54+
features: local

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3576,13 +3576,16 @@ fn translate_to_native_jit(
35763576
&& native_set_ty(ty, *dst, NativeTy::Int, c)
35773577
}
35783578
// Capturing-closure inline (OSR × J2): the closure operand is a
3579-
// native handle; the materialized capture `dst` is an `Int`-class
3580-
// register (the `closure_capture` helper returns scalar bits). A
3581-
// body that uses the capture as a `Float` conflicts here ⇒ bail, so
3582-
// only Int/Bool-class captures inline natively (Float deferred).
3583-
RegInstr::NativeClosureCapture { dst, closure, .. } => {
3579+
// native handle; the materialized capture `dst` carries the
3580+
// capture's scalar bits (the `closure_capture` helper returns the
3581+
// raw i64 bit pattern: an `Int` directly, a `Bool` as 0/1, a
3582+
// `Float` reinterpreted via `f64::to_bits`). Leave `dst`'s class to
3583+
// flow from its uses — exactly like a `GetFieldSlot` read — so an
3584+
// Int/Bool capture stays Int-class and a Float capture becomes
3585+
// Float-class. Lowering admits only a provably Int/Bool/Float `dst`
3586+
// (and the Float arm bit-reinterprets the i64 slot to f64).
3587+
RegInstr::NativeClosureCapture { dst: _, closure, .. } => {
35843588
native_set_ty(ty, *closure, NativeTy::Handle, c)
3585-
&& native_set_ty(ty, *dst, NativeTy::Int, c)
35863589
}
35873590
_ => true,
35883591
};
@@ -3996,10 +3999,16 @@ fn translate_to_native_jit(
39963999
index,
39974000
} => {
39984001
// The closure handle is a native-readable handle; materialize
3999-
// capture `index`'s scalar bits into the `Int`-class `dst` (the
4000-
// inlined body's capture register). A non-scalar capture bails
4001-
// out-of-band in the host helper.
4002-
require(handle_reg(*closure) && int_or_free(*dst))?;
4002+
// capture `index`'s scalar bits into `dst` (the inlined body's
4003+
// capture register). `dst` may be Int/Bool (i64 used directly) or
4004+
// Float (the i64 slot is `f64::to_bits`, bit-reinterpreted to f64 in
4005+
// codegen); an unconstrained `dst` defaults to Int. A non-scalar
4006+
// (Handle/flat) `dst` bails. A non-scalar capture VALUE additionally
4007+
// bails out-of-band in the host helper at runtime.
4008+
require(
4009+
handle_reg(*closure)
4010+
&& (int_or_free(*dst) || bool_ty(*dst) || float(*dst)),
4011+
)?;
40034012
let index = i64::try_from(*index).ok()?;
40044013
let index = u32::try_from(index).ok()?;
40054014
JitInstr::ClosureCapture {
@@ -4355,18 +4364,20 @@ fn translate_osr_loop(
43554364
}
43564365
// Synthetic closure-inline ops (only present when an inlined
43574366
// capturing/monomorphic closure body landed in the OSR region):
4358-
// the closure operand is a native Handle param; an id read or a
4359-
// materialized capture is `Int`-class.
4367+
// the closure operand is a native Handle param; an id read is
4368+
// `Int`-class. A materialized capture's `dst` is left to flow from
4369+
// its uses (Int/Bool/Float) — the helper returns the raw scalar bit
4370+
// pattern (a `Float` via `f64::to_bits`), and lowering admits only a
4371+
// provably Int/Bool/Float `dst`, bit-reinterpreting a Float slot.
43604372
RegInstr::NativeGuardClosureId { closure, .. } => {
43614373
native_set_ty(ty, *closure, NativeTy::Handle, c)
43624374
}
43634375
RegInstr::NativeClosureId { dst, closure } => {
43644376
native_set_ty(ty, *closure, NativeTy::Handle, c)
43654377
&& native_set_ty(ty, *dst, NativeTy::Int, c)
43664378
}
4367-
RegInstr::NativeClosureCapture { dst, closure, .. } => {
4379+
RegInstr::NativeClosureCapture { dst: _, closure, .. } => {
43684380
native_set_ty(ty, *closure, NativeTy::Handle, c)
4369-
&& native_set_ty(ty, *dst, NativeTy::Int, c)
43704381
}
43714382
_ => true,
43724383
};
@@ -4552,7 +4563,14 @@ fn translate_osr_loop(
45524563
JitInstr::ClosureId { dst: r(*dst), base: r(*closure) }
45534564
}
45544565
RegInstr::NativeClosureCapture { dst, closure, index } => {
4555-
require(handle_reg(*closure) && int_or_free(*dst))?;
4566+
// The capture's `dst` may be Int/Bool (i64 slot used directly) or
4567+
// Float (the i64 slot is `f64::to_bits`, bit-reinterpreted to f64
4568+
// in codegen). An unconstrained `dst` defaults to Int. A non-scalar
4569+
// `dst` (Handle/flat array) cannot hold a scalar capture ⇒ bail.
4570+
require(
4571+
handle_reg(*closure)
4572+
&& (int_or_free(*dst) || bool_ty(*dst) || float(*dst)),
4573+
)?;
45564574
let index = u32::try_from(*index).ok()?;
45574575
JitInstr::ClosureCapture { dst: r(*dst), base: r(*closure), index }
45584576
}

crates/rsscript/tests/jit_acceptance.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,6 +1392,119 @@ fn main() -> Unit {
13921392
);
13931393
}
13941394

1395+
/// OSR × J2 POSITIVE test (Pending #2 Float-capture broadening) — a CAPTURING
1396+
/// monomorphic param-handle closure whose capture is a FLOAT, called in a hot loop
1397+
/// inside an I/O-tangled (native-INELIGIBLE) function, MUST inline (bit-reinterpreting
1398+
/// the f64 capture from its i64 slot) and OSR to an allocation-free native loop.
1399+
/// `apply_loop` takes `g: read Fn(Float) -> Float` and calls it every iteration;
1400+
/// `g = |value| value * scale + scale` captures the Float `scale = 1.5`. The capture
1401+
/// is materialized via the `closure_capture` helper, which returns `f64::to_bits` as
1402+
/// i64; the inlined body's Float-class capture register bit-reinterprets that i64 to
1403+
/// f64 (NOT an integer→float conversion). Forcing OSR on, stdout (a Float, formatted
1404+
/// via `String.from_float`) MUST be byte-identical to the pure interpreter AND
1405+
/// `osr_entries > 0` proves the loop inlined the capturing closure + materialized the
1406+
/// FLOAT capture + OSR'd. If the bits were int-converted, the Float would be wrong and
1407+
/// stdout would diverge.
1408+
#[cfg(feature = "native-jit")]
1409+
#[test]
1410+
fn native_osr_j2_float_capture_closure_loop_matches_interpreter() {
1411+
let source = "\
1412+
fn apply_loop(g: read Fn(Float) -> Float, limit: Int, seed: Float) -> Float {
1413+
Log.write(message: read \"begin\")
1414+
let mut i = 0
1415+
let mut total = seed
1416+
let mut x = 0.0
1417+
while i < limit {
1418+
total = total + g(read x)
1419+
x = x + 1.0
1420+
i = i + 1
1421+
}
1422+
Log.write(message: read String.from_float(value: total))
1423+
return total
1424+
}
1425+
1426+
fn main() -> Unit {
1427+
let scale = 1.5
1428+
let g = fn(value) captures(read scale) effects(pure) {
1429+
return value * scale + scale
1430+
}
1431+
Log.write(message: read String.from_float(value: apply_loop(g: read g, limit: read 4000, seed: read 0.0)))
1432+
return Unit
1433+
}
1434+
";
1435+
let file = "jit-osr-j2-float-capture-closure.rss";
1436+
let interp = common::run_vm_source(file, source, &[]).expect("interp run");
1437+
let executable = rsscript::reg_vm_compile_source(file, source).expect("source compiles");
1438+
let (osr, stats) = executable
1439+
.eval_main_with_args_native_osr_with_stats(std::iter::empty::<String>())
1440+
.expect("osr native run");
1441+
assert_eq!(
1442+
interp.stdout, osr.stdout,
1443+
"OSR × J2 Float-capture closure loop must be byte-identical to the interpreter \
1444+
(the f64 capture must be bit-reinterpreted, not int-converted)"
1445+
);
1446+
assert!(
1447+
stats.osr_entries > 0,
1448+
"a capturing monomorphic param-handle closure with a FLOAT capture called in an \
1449+
I/O-tangled hot loop must inline (bit-reinterpreting the f64 capture) and OSR \
1450+
natively: {stats:?}",
1451+
);
1452+
}
1453+
1454+
/// OSR × J2 NEGATIVE test (Pending #2) — a closure capturing a NON-scalar value that
1455+
/// HAPPENS TO BE float-typed (`scales: List<Float>`, a heap value) MUST NOT OSR. The
1456+
/// Float broadening admits only FLAT scalar captures (Int/Bool/Float); a heap capture
1457+
/// — even one whose elements are Floats — cannot be materialized as a scalar via the
1458+
/// `closure_capture` helper, so the inline gate's `captures_all_scalar` profile bit
1459+
/// goes false on the first observation, the site never inlines, the per-iteration
1460+
/// `CallClosure` keeps the loop off the native subset, and `osr_entries` MUST stay 0.
1461+
/// Output must still be interpreter-identical. This guards the Float broadening from
1462+
/// over-reaching: widening flat-scalar captures to include `Float` must NOT start
1463+
/// admitting a heap aggregate just because it carries Floats (which would read garbage
1464+
/// bits from a heap handle's slot).
1465+
#[cfg(feature = "native-jit")]
1466+
#[test]
1467+
fn native_osr_j2_float_heap_capture_closure_does_not_osr() {
1468+
let source = "\
1469+
fn apply_loop(g: read Fn(Int) -> Float, limit: Int, seed: Float) -> Float {
1470+
Log.write(message: read \"begin\")
1471+
let mut i = 0
1472+
let mut total = seed
1473+
while i < limit {
1474+
total = total + g(i)
1475+
i = i + 1
1476+
}
1477+
Log.write(message: read String.from_float(value: total))
1478+
return total
1479+
}
1480+
1481+
fn main() -> Unit {
1482+
let scales: List<Float> = [1.5, 2.25, 3.75]
1483+
let g = fn(value) captures(read scales) effects(pure) {
1484+
let n = List.len<Float>(list: read scales)
1485+
return List.get<Float>(list: read scales, index: value - (value / n) * n)
1486+
}
1487+
Log.write(message: read String.from_float(value: apply_loop(g: read g, limit: read 4000, seed: read 0.0)))
1488+
return Unit
1489+
}
1490+
";
1491+
let file = "jit-osr-j2-float-heap-capture-closure.rss";
1492+
let interp = common::run_vm_source(file, source, &[]).expect("interp run");
1493+
let executable = rsscript::reg_vm_compile_source(file, source).expect("source compiles");
1494+
let (osr, stats) = executable
1495+
.eval_main_with_args_native_osr_with_stats(std::iter::empty::<String>())
1496+
.expect("osr native run");
1497+
assert_eq!(
1498+
interp.stdout, osr.stdout,
1499+
"a closure capturing a List<Float> (heap) must still be interpreter-identical under OSR"
1500+
);
1501+
assert_eq!(
1502+
stats.osr_entries, 0,
1503+
"a closure capturing a heap value (List<Float>) must NOT OSR even though its \
1504+
elements are Floats — only FLAT scalar captures inline: {stats:?}",
1505+
);
1506+
}
1507+
13951508
/// OSR × J2 NEGATIVE test — a closure with a NON-scalar (heap) capture MUST NOT OSR.
13961509
/// `g` captures `tag: List<Int>` (a heap value) and reads its length each call, so
13971510
/// the capture cannot be materialized as a scalar via the `closure_capture` helper:

crates/vm-jit/src/lib.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,7 +1334,15 @@ fn validate(program: &JitFunction) -> Result<(), JitError> {
13341334
}
13351335
JitInstr::ClosureCapture { dst, base, .. } => {
13361336
require_class(*base, JitValueType::Handle, "ClosureCapture base")?;
1337-
require_class(*dst, JitValueType::Int, "ClosureCapture result")?;
1337+
// The capture result `dst` is Int-class (an Int/Bool capture, used
1338+
// as i64 directly) or Float-class (a Float capture, whose i64 slot
1339+
// is `f64::to_bits` and is bit-reinterpreted to f64 in codegen).
1340+
if !matches!(class(*dst), JitValueType::Int | JitValueType::Float) {
1341+
return Err(JitError(format!(
1342+
"ClosureCapture result: register {dst} is {:?}, expected Int or Float",
1343+
class(*dst)
1344+
)));
1345+
}
13381346
}
13391347
JitInstr::FieldHandle { dst, base, .. } => {
13401348
require_class(*base, JitValueType::Handle, "FieldHandle base")?;
@@ -2634,7 +2642,17 @@ fn build_function(
26342642
deopt!(i),
26352643
);
26362644
bcx.switch_to_block(cont);
2637-
bcx.def_var(reg(*dst), result);
2645+
// The helper returns the capture's raw scalar bits as i64. For an
2646+
// Int/Bool-class `dst` that IS the value; for a Float-class `dst`
2647+
// the i64 is `f64::to_bits`, so bit-reinterpret it to f64 (NOT an
2648+
// integer-to-float conversion) — the same convention by which a
2649+
// float register's arg slot is loaded as f64 on entry.
2650+
let stored = if program.is_float(*dst) {
2651+
bcx.ins().bitcast(types::F64, MemFlags::new(), result)
2652+
} else {
2653+
result
2654+
};
2655+
bcx.def_var(reg(*dst), stored);
26382656
}
26392657
JitInstr::FieldHandle { dst, base, slot } => {
26402658
// Fetch a heap field (e.g. a stored closure) as a fresh handle.

0 commit comments

Comments
 (0)