Skip to content

Commit 9ca929f

Browse files
olwangclaude
andcommitted
rsscript: fast path for bulk List<Float> ops in the reg-VM
Recognize a numeric-binary folder closure (|acc, x| acc <op> x, no captures, body = one arithmetic instr + Return) and run List.fold over a list of scalar Int/Float values as a tight loop calling the same eval_numeric_binary the interpreter uses, in the same operand order, on the same values. This skips per-element frame setup + bytecode dispatch. Bit-identical by construction: identical f64 ops, order, NaN/inf, and error behavior. Any non-matching case (wrong closure shape, non-scalar element, captures) falls back to the existing per-element path. Measured ~7.2x faster on a 50k-element float fold repeated 50x (871ms -> 121ms) with identical results. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 025b6ec commit 9ca929f

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8813,6 +8813,38 @@ impl RegVm {
88138813
folder: &VmClosure,
88148814
base: usize,
88158815
) -> Result<VmValue, EvalError> {
8816+
// Fast path: a fold whose folder is a recognized simple numeric binary
8817+
// closure (`|acc, x| acc <op> x`) over a list of scalar `Int`/`Float`
8818+
// values is the hot shape for sum/product-style reductions. Running it as
8819+
// a tight loop over the element values — calling the *same*
8820+
// `eval_numeric_binary` the interpreter uses, in the *same* operand
8821+
// order, on the *same* values — avoids a full frame setup + bytecode
8822+
// dispatch per element while producing bit-identical results (identical
8823+
// f64 ops, order, NaN/inf, and error behavior). Any case that does not
8824+
// exactly match (wrong shape, non-scalar element, captures present)
8825+
// falls through to the generic interpreter path below.
8826+
if let Some(form) = recognize_numeric_binary_closure(unit, folder) {
8827+
if matches!(state, VmValue::Int(_) | VmValue::Float(_)) {
8828+
let list = list.borrow();
8829+
if list
8830+
.iter()
8831+
.all(|item| matches!(item, VmValue::Int(_) | VmValue::Float(_)))
8832+
{
8833+
for item in list.iter() {
8834+
// Preserve the closure's operand order exactly: `state`
8835+
// and `item` are placed at the two param registers, so
8836+
// whichever param the lhs/rhs reads determines the order.
8837+
let (lhs, rhs) = if form.lhs_is_state {
8838+
(&state, item)
8839+
} else {
8840+
(item, &state)
8841+
};
8842+
state = eval_numeric_binary(form.op, lhs, rhs)?;
8843+
}
8844+
return Ok(state);
8845+
}
8846+
}
8847+
}
88168848
let len = list.borrow().len();
88178849
for index in 0..len {
88188850
let item = list_item_at(&list, index, "List.fold")?;
@@ -13064,6 +13096,71 @@ fn int_overflow_error(operation: &str, lhs: i64, rhs: i64) -> EvalError {
1306413096
))
1306513097
}
1306613098

13099+
/// A folder closure recognized as a single numeric binary op over its two
13100+
/// parameters: `op` is the arithmetic operator and `lhs_is_state` says whether
13101+
/// the op's left operand is the accumulator (param 0) — i.e. `acc <op> x` — or
13102+
/// the element (`x <op> acc`). Used to fast-path `List.fold` without losing the
13103+
/// closure's exact operand order.
13104+
struct NumericBinaryClosure {
13105+
op: BinaryOp,
13106+
lhs_is_state: bool,
13107+
}
13108+
13109+
/// Recognize `|state, x| state <op> x` (or `x <op> state`) closures with no
13110+
/// captures whose body is exactly one arithmetic instruction returning its
13111+
/// result. Returns `None` for anything else so the caller falls back to the
13112+
/// generic interpreter (the recognizer is intentionally conservative — a missed
13113+
/// match only forgoes a speedup, never changes results).
13114+
fn recognize_numeric_binary_closure(
13115+
unit: &RegUnit,
13116+
closure: &VmClosure,
13117+
) -> Option<NumericBinaryClosure> {
13118+
// The fast path supplies the two operands by value at the param registers;
13119+
// captured values would not be supplied, so require a capture-free closure.
13120+
if !closure.captures.is_empty() {
13121+
return None;
13122+
}
13123+
let function = &unit.functions[closure.function];
13124+
if function.params != 2 {
13125+
return None;
13126+
}
13127+
// Param registers: captures occupy `0..captures.len()` (none here), then the
13128+
// two params at registers 0 and 1.
13129+
let state_reg = 0usize;
13130+
let item_reg = 1usize;
13131+
let [instr, RegInstr::Return { src }] = function.code.as_slice() else {
13132+
return None;
13133+
};
13134+
let (op, dst, lhs, rhs) = arithmetic_binop_parts(instr)?;
13135+
if dst != *src {
13136+
return None;
13137+
}
13138+
// Both operands must be exactly the two distinct param registers (so every
13139+
// input is a supplied param and nothing else is read).
13140+
let lhs_is_state = if lhs == state_reg && rhs == item_reg {
13141+
true
13142+
} else if lhs == item_reg && rhs == state_reg {
13143+
false
13144+
} else {
13145+
return None;
13146+
};
13147+
Some(NumericBinaryClosure { op, lhs_is_state })
13148+
}
13149+
13150+
/// If `instr` is one of the numeric arithmetic instructions handled by
13151+
/// [`eval_numeric_binary`], return `(op, dst, lhs, rhs)`. Bitwise/shift ops are
13152+
/// excluded: they are `Int`-only and not routed through `eval_numeric_binary`.
13153+
fn arithmetic_binop_parts(instr: &RegInstr) -> Option<(BinaryOp, Reg, Reg, Reg)> {
13154+
match instr {
13155+
RegInstr::AddInt { dst, lhs, rhs } => Some((BinaryOp::Add, *dst, *lhs, *rhs)),
13156+
RegInstr::SubInt { dst, lhs, rhs } => Some((BinaryOp::Subtract, *dst, *lhs, *rhs)),
13157+
RegInstr::MulInt { dst, lhs, rhs } => Some((BinaryOp::Multiply, *dst, *lhs, *rhs)),
13158+
RegInstr::DivInt { dst, lhs, rhs } => Some((BinaryOp::Divide, *dst, *lhs, *rhs)),
13159+
RegInstr::ModInt { dst, lhs, rhs } => Some((BinaryOp::Modulo, *dst, *lhs, *rhs)),
13160+
_ => None,
13161+
}
13162+
}
13163+
1306713164
fn eval_numeric_binary(op: BinaryOp, lhs: &VmValue, rhs: &VmValue) -> Result<VmValue, EvalError> {
1306813165
match (lhs, rhs) {
1306913166
(VmValue::Int(lhs), VmValue::Int(rhs)) => match op {

crates/rsscript/tests/vm_eval.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,99 @@ fn main() -> Int {
5858
assert_eq!(output.value, "7");
5959
}
6060

61+
/// Build a program that folds a large `List<Float>` with `folder_body` and
62+
/// returns the sum formatted as a string. The list values are deterministic so
63+
/// the fast and slow folders must agree bit-for-bit.
64+
fn float_fold_program(folder_body: &str) -> String {
65+
// Build the list once, then fold it many times so the fold (not the one-time
66+
// list construction) dominates the measured time.
67+
format!(
68+
r#"features: local
69+
70+
fn main() -> Float {{
71+
let mut index = 0
72+
local values = List<Float>.new()
73+
while index < 50000 {{
74+
let f = Int.to_float(value: read index)
75+
List.push<Float>(list: mut values, value: read (f * 0.5 - 1.0))
76+
index = index + 1
77+
}}
78+
let mut acc = 0.0
79+
let mut rep = 0
80+
while rep < 50 {{
81+
let total = List.fold<Float, Float>(
82+
list: read values,
83+
initial: read 0.0,
84+
folder: {folder_body},
85+
)
86+
acc = acc + total
87+
rep = rep + 1
88+
}}
89+
return acc
90+
}}
91+
"#
92+
)
93+
}
94+
95+
/// The bulk `List<Float>.fold` fast path must be a pure performance change: a
96+
/// recognized numeric folder (`|acc, x| acc + x`) and an equivalent folder the
97+
/// recognizer rejects (an extra `let` binding forces the generic interpreter
98+
/// path) must produce *bit-identical* results, and the fast path should be
99+
/// materially faster on a large list.
100+
#[test]
101+
fn float_fold_fast_path_matches_slow_path_and_is_faster() {
102+
use std::time::Instant;
103+
104+
// Recognized shape: body is exactly `<binop>; Return`.
105+
let fast_src = float_fold_program("|acc, x| acc + x");
106+
// Rejected shape: identical math, but the extra statement means the body is
107+
// not `[binop, Return]`, so the recognizer declines and the generic
108+
// per-element closure path runs.
109+
let slow_src = float_fold_program("|acc, x| { let y = x\n return acc + y }");
110+
111+
// Warm up + correctness: both paths must yield the same f64 string.
112+
let fast0 = eval_source_main("float-fold-fast.rss", &fast_src).expect("fast eval");
113+
let slow0 = eval_source_main("float-fold-slow.rss", &slow_src).expect("slow eval");
114+
assert_eq!(
115+
fast0.value, slow0.value,
116+
"fast and slow float fold must be bit-identical"
117+
);
118+
assert_eq!(fast0.native_value, slow0.native_value);
119+
120+
let reps = 3;
121+
let mut fast_ns = u128::MAX;
122+
let mut slow_ns = u128::MAX;
123+
for _ in 0..reps {
124+
let t = Instant::now();
125+
let r = eval_source_main("float-fold-fast.rss", &fast_src).expect("fast eval");
126+
fast_ns = fast_ns.min(t.elapsed().as_nanos());
127+
assert_eq!(r.value, fast0.value);
128+
129+
let t = Instant::now();
130+
let r = eval_source_main("float-fold-slow.rss", &slow_src).expect("slow eval");
131+
slow_ns = slow_ns.min(t.elapsed().as_nanos());
132+
assert_eq!(r.value, slow0.value);
133+
}
134+
135+
let speedup = slow_ns as f64 / fast_ns as f64;
136+
println!(
137+
"float fold (50k elems x50 folds): fast={:.2}ms slow={:.2}ms speedup={:.2}x result={}",
138+
fast_ns as f64 / 1.0e6,
139+
slow_ns as f64 / 1.0e6,
140+
speedup,
141+
fast0.value,
142+
);
143+
// The fast path must not regress; on a 200k-element fold it is far faster
144+
// than the per-element closure interpreter. Keep the assertion margin modest
145+
// to stay robust under CI scheduling noise while still catching a regression
146+
// that lost the fast path entirely.
147+
assert!(
148+
speedup > 2.0,
149+
"expected the bulk float-fold fast path to be materially faster, got {speedup:.2}x \
150+
(fast={fast_ns}ns slow={slow_ns}ns)"
151+
);
152+
}
153+
61154
#[test]
62155
fn eval_package_runs_merged_sources_with_args() {
63156
let package_dir = common::unique_temp_dir("rsscript-eval-package");

0 commit comments

Comments
 (0)