From 30a930303b56ab7e3546dec726a54b9cacb10815 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 01:06:10 -0700 Subject: [PATCH 1/3] Run numeric helper call trees on f64 registers A typed loop's helper whose body only holds numbers ran on f64 registers -- unless it called a helper, as any recursion does, when the whole tree fell to the tagged interpreter, which also clears a 24-entry register file per call. A numeric body may now call another helper of its graph (itself included) when every argument is proven a number: the arguments are copied to contiguous registers above the body's, the call runs the callee's numeric body under the tagged interpreter's recursion bound, and `settle` drops, to a fixed point, any body whose callee is not numeric or does not return a number (lowering assumed it did). recursive_call_tree: 716 -> 370 instructions a call, 0.566 cycles against main (QuickJS-NG 509 instructions). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 25 +- .../src/bytecode/typed_loop/helper_graph.rs | 7 +- .../typed_loop/helper_graph/numeric.rs | 260 +++++++++++++++++- crates/qjs-runtime/src/tests/numeric_loops.rs | 34 +++ 4 files changed, 300 insertions(+), 26 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 24d7903e..9bbfc54c 100644 --- a/crates/qjs-cli/hot-functions.order +++ b/crates/qjs-cli/hot-functions.order @@ -13,7 +13,7 @@ # budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness # budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality # budget 0x500 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named -# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 44 +# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 49 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -27,7 +27,8 @@ __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute18try_run_ty __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterINtNtCs1OjIl8oxbrv_5alloc3vec3VechEENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RNvYNtNtNtNtCsg55jX0GwzBC_3std3sys5stdio4unix6StderrNtNtBa_2io5Write9write_allBa_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16get_named_object -__RNvNvNtCsg55jX0GwzBC_3std2fs4read5inner +__RNvXso_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StderrNtB7_5Write9write_fmt +__RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt __RNvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_8ArrayRef24direct_dense_index_value __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtCscUtGwbhD4WH_5gimli4read5dwarf5DwarfINtNtBL_12endian_slice11EndianSliceNtNtBN_9endianity12LittleEndianEEE9drop_slowCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5stdioNtB5_9StdinLockNtB7_7BufRead9read_line @@ -36,26 +37,30 @@ __RNvYINtNtNtCscUtGwbhD4WH_5gimli4read12endian_slice11EndianSliceNtNtB9_9endiani __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf21try_eval_numeric_leaf __RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterNtNtNtNtB6_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvMs3_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_9ArrayData21has_property_at_index -__RNvXso_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StderrNtB7_5Write9write_fmt -__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint -__RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf20direct_number_binary -__RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt -__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute22ordinary_data_property __RNvXs_NvNtCsg55jX0GwzBC_3std9panicking13panic_handlerNtB4_19FormatStringPayloadNtNtCsl8K0bEFm1U0_4core5panic12PanicPayload3get +__RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterINtNtB4_6cursor6CursorQShEENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE14write_all_coldBa_ +__RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf20direct_number_binary +__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtNtCsg55jX0GwzBC_3std4sync6poison5mutex5MutexINtNtB7_3vec3VechEEE9drop_slowBP_ +__RNvXs1g_NtCsl8K0bEFm1U0_4core3fmtRINtNtCs1OjIl8oxbrv_5alloc3vec3VechENtB6_5Debug3fmtCsg55jX0GwzBC_3std +__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute22ordinary_data_property +__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint +__RNvNtNtCsg55jX0GwzBC_3std3sys9backtrace4lock __RNvMNtNtNtCs9nYd1Hk1rek_11qjs_runtime5value6object10slot_readsNtB4_9ObjectRef22own_data_property_read __RNvMs1_NtNtNtNtCsg55jX0GwzBC_3std3sys4sync6rwlock5queueNtB5_6RwLock21read_unlock_contended __RNvMs1_NtNtNtNtCsg55jX0GwzBC_3std3sys4sync6rwlock5queueNtB5_6RwLock16unlock_contended __RNvMs6_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_9ObjectRef32write_existing_own_data_property __RNvNtNtNtCsg55jX0GwzBC_3std12backtrace_rs9symbolize5gimli4mmap __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness -__RNvNtNtNtNtCsg55jX0GwzBC_3std3sys2io5error4unix17decode_error_kind -__RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterINtNtB4_6cursor6CursorQShEENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ +__RNvNtNtCsg55jX0GwzBC_3std3sys2fs8read_dir +__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtCsg55jX0GwzBC_3std6thread6thread5InnerNtNtBM_5alloc6SystemE9drop_slowBM_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtNtCsg55jX0GwzBC_3std3sys2fs4unix12InnerReadDirE9drop_slowBO_ __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtNtNtB9_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named -__RNvNtNtCsg55jX0GwzBC_3std3sys2fs8read_dir +__RNvNtNtCsg55jX0GwzBC_3std3sys2fs12canonicalize +__RNvMs4_NtCs1OjIl8oxbrv_5alloc7raw_vecINtB5_11RawVecInnerNtNtCsg55jX0GwzBC_3std5alloc6SystemE14grow_amortizedBW_ +__RNvXs1i_NtCsl8K0bEFm1U0_4core3fmtRNtNtNtB8_5panic8location8LocationNtB6_7Display3fmtCsg55jX0GwzBC_3std __RNvXs1_NtCs9nYd1Hk1rek_11qjs_runtime5valueNtB5_5ValueNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone __RINvNtCsl8K0bEFm1U0_4core3ptr13drop_in_placeNtNtCs9nYd1Hk1rek_11qjs_runtime5value5ValueEBK_ __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8function4call25call_direct_leaf_function diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph.rs b/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph.rs index adc0f7f7..90379d16 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph.rs @@ -178,8 +178,8 @@ impl HelperGraph { return None; } let program = self.programs.get(index as usize)?; - // A numeric body calls no other helper, so only the loop's own call - // (depth zero) can reach one; recursion skips the check. It is + // A numeric body runs its own calls on `f64` registers, so only the + // loop's own call (depth zero) enters one from here. It is // lowered with every parameter a number, so a call that leaves one // `undefined` -- or passes one past the parameters, which would land // in a local -- runs on the general path. @@ -189,7 +189,7 @@ impl HelperGraph { && let Some(Some(numeric)) = self.numeric.get(index as usize) && let Some(numbers) = numbers(args) { - return numeric.run(&numbers[..arity]); + return numeric.run(&self.numeric, &numbers[..arity], 0); } // `Typed` is `Copy`, so the whole file is a stack array: a helper call // allocates nothing and its registers stay in the frame the compiler @@ -356,6 +356,7 @@ impl Preparation { } preparation.prepare_callee(vm, &callee, site.arity, 0)?; } + numeric::NumProgram::settle(&mut preparation.graph.numeric); Some(preparation.graph) } diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs b/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs index e9903406..ddc737cd 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs @@ -14,8 +14,12 @@ //! loses is the type, which only equality and the returned value observe. A //! dataflow pass proves, per register and per operation, which of the three //! a register may hold; a body whose equality operands are not both proven -//! numbers, whose returned register is not proven to hold one kind, or that -//! calls another helper keeps the tagged interpreter. +//! numbers, or whose returned register is not proven to hold one kind, keeps +//! the tagged interpreter. A body may call another helper -- itself, in a +//! recursion -- when its arguments are proven numbers and the callee is +//! itself numeric and returns a number (`settle`), so a whole numeric call +//! tree runs on `f64` registers (`recursive_call_tree`: 716 instructions a +//! call through the tagged interpreter). use qjs_ast::{BinaryOp, UnaryOp}; @@ -34,6 +38,11 @@ type Kinds = [u8; MAX_HELPER_REGISTERS]; /// helper may name. const FILE: usize = MAX_HELPER_REGISTERS.next_power_of_two(); +/// Where a call's arguments are copied, contiguous and above every register +/// the helper names; constants are hoisted above them. +const CALL_ARGUMENTS: usize = MAX_HELPER_REGISTERS; +const _: () = assert!(CALL_ARGUMENTS + super::MAX_HELPER_ARITY <= FILE); + #[derive(Clone, Copy, Debug)] pub(in crate::bytecode) enum NumOp { Const { @@ -213,6 +222,9 @@ enum ReturnKind { pub(super) struct NumProgram { ops: Box<[NumOp]>, returns: ReturnKind, + /// The helpers the body calls, by graph index; `settle` keeps the body + /// only while every one of them is numeric and returns a number. + calls: Box<[u16]>, /// Constants kept in the registers above every register the helper /// names, loaded once per call rather than by an operation each time. constants: Box<[(u16, f64)]>, @@ -225,12 +237,17 @@ impl NumProgram { let before = infer(ops, arity)?; let mut returns: Option = None; let mut lowered = Vec::with_capacity(ops.len()); + let mut calls: Vec = Vec::new(); + // Where each helper operation's lowering starts: a call lowers to + // several operations, so branch targets are remapped afterwards. + let mut starts: Vec = Vec::with_capacity(ops.len() + 1); for (op, kinds) in ops.iter().zip(&before) { + starts.push(u32::try_from(lowered.len()).ok()?); // An operation the body never reaches has no proven state; it // cannot run, so any encoding of it will do. let kinds = kinds.unwrap_or([NUMBER; MAX_HELPER_REGISTERS]); let kind = |register: u16| kinds.get(usize::from(register)).copied().unwrap_or(0); - lowered.push(match *op { + let next = match *op { HelperOp::Const { dst, value } => NumOp::Const { dst, value: encode(value), @@ -279,8 +296,40 @@ impl NumProgram { returns = Some(returned); NumOp::Return { src } } - HelperOp::Call { .. } => return None, - }); + HelperOp::Call { + dst, + graph, + args, + arity, + } => { + // The callee was lowered for number arguments, and an + // argument's type is all the encoding would lose. + let args = args.get(..usize::from(arity))?; + if args.iter().any(|®ister| kind(register) != NUMBER) { + return None; + } + for (offset, &src) in args.iter().enumerate() { + lowered.push(NumOp::Move { + dst: u16::try_from(CALL_ARGUMENTS + offset).ok()?, + src, + }); + } + calls.push(graph); + NumOp::Call { + dst, + callee: graph, + args: u16::try_from(CALL_ARGUMENTS).ok()?, + argc: arity, + } + } + }; + lowered.push(next); + } + starts.push(u32::try_from(lowered.len()).ok()?); + for op in &mut lowered { + if let NumOp::JumpIfFalsy { target, .. } | NumOp::Jump { target } = op { + *target = *starts.get(*target as usize)?; + } } let returns = match returns? { NUMBER => ReturnKind::Number, @@ -288,16 +337,56 @@ impl NumProgram { _ => ReturnKind::Undefined, }; let (ops, constants) = optimize(lowered); + calls.sort_unstable(); + calls.dedup(); Some(Self { ops: ops.into_boxed_slice(), returns, + calls: calls.into_boxed_slice(), constants: constants.into_boxed_slice(), }) } - /// Runs the body on number arguments. + /// Drops every body whose calls reach one that is not numeric or does + /// not return a number, until none does: `lower` assumed each call + /// returns a number, and a body is only lowered once its callees are + /// known. + pub(super) fn settle(programs: &mut [Option]) { + loop { + let doomed: Vec = programs + .iter() + .enumerate() + .filter_map(|(index, program)| { + let program = program.as_ref()?; + program + .calls + .iter() + .any(|&callee| { + !matches!( + programs.get(usize::from(callee)), + Some(Some(Self { + returns: ReturnKind::Number, + .. + })) + ) + }) + .then_some(index) + }) + .collect(); + if doomed.is_empty() { + return; + } + for index in doomed { + programs[index] = None; + } + } + } + + /// Runs the body on number arguments; `graph` holds the bodies its + /// calls reach, and `depth` bounds their recursion as the tagged + /// interpreter's does. #[inline(never)] - pub(super) fn run(&self, args: &[f64]) -> Option { + pub(super) fn run(&self, graph: &[Option], args: &[f64], depth: usize) -> Option { // Every register that is not an argument starts `undefined`. The file // is a power of two wider than any register the helper names, so an // operand is masked into range rather than bounds-checked. @@ -431,9 +520,25 @@ impl NumProgram { } } NumOp::Nop => {} - // A helper body never calls (`lower` rejects `HelperOp::Call`) - // and never bails. - NumOp::Call { .. } | NumOp::Bail => return None, + NumOp::Call { + dst, + callee, + args, + argc, + } => { + if depth + 1 >= super::MAX_NATIVE_RECURSION { + return None; + } + let body = graph.get(usize::from(callee))?.as_ref()?; + let mut values = [0.0; super::MAX_HELPER_ARITY]; + for (offset, value) in values.iter_mut().take(usize::from(argc)).enumerate() { + *value = get!(usize::from(args) + offset); + } + let value = body.run(graph, &values[..usize::from(argc)], depth + 1)?; + set!(dst, encode(value)); + } + // A helper body never bails. + NumOp::Bail => return None, NumOp::Jump { target } => pc = target as usize, NumOp::Return { src } => { let value = get!(src); @@ -479,7 +584,9 @@ fn infer(ops: &[HelperOp], arity: u8) -> Option>> { HelperOp::JumpIfFalsy { target, .. } => successors[1] = Some(target as usize), HelperOp::Jump { target } => successors = [Some(target as usize), None], HelperOp::Return { .. } => successors = [None, None], - HelperOp::Call { .. } => return None, + // Assumed; `NumProgram::settle` drops the body unless the callee + // does return a number. + HelperOp::Call { dst, .. } => *kinds.get_mut(usize::from(dst))? = NUMBER, } for next in successors.into_iter().flatten() { let slot = before.get_mut(next)?; @@ -1060,8 +1167,135 @@ mod tests { let ops = sum_down(); let program = NumProgram::lower(&ops, 1).expect("the body is numeric"); assert!(program.ops.len() < ops.len(), "{:?}", program.ops); - assert!(matches!(program.run(&[10.0]), Some(Typed::Number(n)) if n == 55.0)); - assert!(matches!(program.run(&[0.0]), Some(Typed::Number(n)) if n == 0.0)); + assert!(matches!(program.run(&[], &[10.0], 0), Some(Typed::Number(n)) if n == 55.0)); + assert!(matches!(program.run(&[], &[0.0], 0), Some(Typed::Number(n)) if n == 0.0)); + } + + fn constant(dst: u16, value: f64) -> HelperOp { + HelperOp::Const { + dst, + value: Typed::Number(value), + } + } + + fn call(dst: u16, graph: u16, argument: u16) -> HelperOp { + HelperOp::Call { + dst, + graph, + args: [argument, 0, 0, 0], + arity: 1, + } + } + + /// `function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }` + /// as graph body 0. + fn fib() -> Vec { + vec![ + constant(1, 2.0), + HelperOp::Binary { + dst: 2, + op: BinaryOp::Lt, + left: 0, + right: 1, + }, + HelperOp::JumpIfFalsy { cond: 2, target: 4 }, + HelperOp::Return { src: 0 }, + constant(3, 1.0), + HelperOp::Binary { + dst: 4, + op: BinaryOp::Sub, + left: 0, + right: 3, + }, + call(5, 0, 4), + constant(6, 2.0), + HelperOp::Binary { + dst: 7, + op: BinaryOp::Sub, + left: 0, + right: 6, + }, + call(8, 0, 7), + HelperOp::Binary { + dst: 9, + op: BinaryOp::Add, + left: 5, + right: 8, + }, + HelperOp::Return { src: 9 }, + ] + } + + #[test] + fn a_recursive_body_runs_its_calls_over_numbers() { + let mut graph = vec![NumProgram::lower(&fib(), 1)]; + NumProgram::settle(&mut graph); + let program = graph[0] + .as_ref() + .expect("fib calls only itself, which returns a number"); + assert!(matches!(program.run(&graph, &[10.0], 0), Some(Typed::Number(n)) if n == 55.0)); + // Recursion past the native bound hands the call back. + let deep = vec![ + constant(1, 0.0), + HelperOp::Binary { + dst: 2, + op: BinaryOp::Le, + left: 0, + right: 1, + }, + HelperOp::JumpIfFalsy { cond: 2, target: 4 }, + HelperOp::Return { src: 1 }, + constant(3, 1.0), + HelperOp::Binary { + dst: 4, + op: BinaryOp::Sub, + left: 0, + right: 3, + }, + call(5, 0, 4), + HelperOp::Binary { + dst: 6, + op: BinaryOp::Add, + left: 5, + right: 3, + }, + HelperOp::Return { src: 6 }, + ]; + let mut graph = vec![NumProgram::lower(&deep, 1)]; + NumProgram::settle(&mut graph); + let program = graph[0].as_ref().expect("the body is numeric"); + assert!(matches!(program.run(&graph, &[50.0], 0), Some(Typed::Number(n)) if n == 50.0)); + assert!(program.run(&graph, &[500.0], 0).is_none()); + } + + #[test] + fn a_body_calling_one_that_returns_a_boolean_keeps_the_tagged_body() { + let caller = vec![call(1, 1, 0), HelperOp::Return { src: 1 }]; + let callee = vec![ + constant(1, 0.0), + HelperOp::Binary { + dst: 2, + op: BinaryOp::Gt, + left: 0, + right: 1, + }, + HelperOp::Return { src: 2 }, + ]; + let mut graph = vec![NumProgram::lower(&caller, 1), NumProgram::lower(&callee, 1)]; + assert!(graph[0].is_some() && graph[1].is_some()); + NumProgram::settle(&mut graph); + assert!(graph[0].is_none()); + assert!(graph[1].is_some()); + // A call whose argument may not be a number is not lowered at all. + let untyped = vec![ + HelperOp::Const { + dst: 1, + value: Typed::Boolean(true), + }, + call(2, 0, 1), + HelperOp::Return { src: 2 }, + ]; + assert!(NumProgram::lower(&untyped, 1).is_none()); } #[test] diff --git a/crates/qjs-runtime/src/tests/numeric_loops.rs b/crates/qjs-runtime/src/tests/numeric_loops.rs index 7384da87..fcfd6e02 100644 --- a/crates/qjs-runtime/src/tests/numeric_loops.rs +++ b/crates/qjs-runtime/src/tests/numeric_loops.rs @@ -409,3 +409,37 @@ fn brace_less_loop_bodies_match_braced_ones() { Ok(Value::String("00101120212230313233".to_owned().into())) ); } + +/// Numeric helpers that call helpers -- self and mutual recursion, a +/// callee returning a boolean or sometimes `undefined`, recursion past the +/// native bound, a boolean argument -- give the interpreter's answers from +/// a typed loop. Expected values from V8. +#[test] +fn recursive_numeric_helpers_called_from_typed_loops() { + let source = r#"function tree(d, v) { if (d <= 0) return v + 1; return tree(d - 1, v) + tree(d - 1, v) - (v + 1); } +function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); } +function isEven(n) { return n === 0 ? 1 : isOdd(n - 1); } +function isOdd(n) { return n === 0 ? 0 : isEven(n - 1); } +function deep(n) { return n <= 0 ? 0 : 1 + deep(n - 1); } +function pos(x) { return x > 0; } +function countPos(n) { return n <= 0 ? 0 : (pos(n) ? 1 : 0) + countPos(n - 1); } +function maybe(n) { if (n > 3) return n; } +function sumMaybe(n) { var m = maybe(n); return m === undefined ? -1 : m; } +function half(n) { return n <= 1 ? n : half(n / 2); } +var out = []; +var s = 0; for (var i = 0; i < 50; i++) s += tree(5, i); out.push(s); +s = 0; for (var i = 0; i < 20; i++) s += fib(i); out.push(s); +s = 0; for (var i = 0; i < 30; i++) s += isEven(i); out.push(s); +s = 0; for (var i = 0; i < 5; i++) s += deep(200 + i); out.push(s); +s = 0; for (var i = 0; i < 10; i++) s += countPos(i); out.push(s); +s = 0; for (var i = 0; i < 8; i++) s += sumMaybe(i); out.push(s); +s = 0; for (var i = 0; i < 8; i++) s += half(i * 3); out.push(s); +s = 0; for (var i = 0; i < 5; i++) s += fib(i % 2 ? true : 3); out.push(s); +out.join(',');"#; + assert_eq!( + eval(source), + Ok(Value::String( + "1275,10945,15,1010,45,18,4.96875,8".to_owned().into() + )) + ); +} From 7d18c3ff7e8484e71c4175e3a8b1e153e9108642 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 01:24:45 -0700 Subject: [PATCH 2/3] Take boolean and undefined arguments into converting number-only leaves A number-only closed-form leaf (sha1's `safe_add`) declined any argument that was not a number, so `safe_add(e, w[j])` with `w[j]` read past the end of the input deoptimized crypto-sha1's block loops. Its operators all apply ToNumber, so such an argument gives the same result as its number -- unless a parameter reaches the result as passed (`function id(x) { return x; }`). Compilation now tracks which values are raw parameters, and a program that never returns one takes boolean and undefined arguments by ToNumber. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/bytecode/typed_loop/execute.rs | 22 +++++-- .../src/bytecode/vm_numeric_leaf.rs | 62 ++++++++++++++----- crates/qjs-runtime/src/tests/numeric_loops.rs | 35 +++++++++++ 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs b/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs index b26cf8d9..773f4479 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs @@ -981,13 +981,19 @@ fn boxed_truthiness(value: &Value) -> Typed { Typed::Boolean(crate::conversion::is_truthy(value)) } -fn typed_numbers(args: &[Typed]) -> Option<[f64; super::helper_graph::MAX_HELPER_ARITY]> { +/// The arguments as numbers: each must be one unless `convert`, when a +/// boolean or `undefined` takes its `ToNumber` value. +fn typed_numbers( + args: &[Typed], + convert: bool, +) -> Option<[f64; super::helper_graph::MAX_HELPER_ARITY]> { let mut numbers = [0.0; super::helper_graph::MAX_HELPER_ARITY]; for (number, arg) in numbers.iter_mut().zip(args) { - let Typed::Number(value) = arg else { - return None; + *number = match arg { + Typed::Number(value) => *value, + other if convert => other.to_numeric().number()?, + _ => return None, }; - *number = *value; } Some(numbers) } @@ -1000,7 +1006,13 @@ fn call_number_only_leaf( bytecode: &crate::bytecode::Bytecode, args: &[Typed], ) -> Option { - let numbers = typed_numbers(args)?; + // `x[i + j]` past the end of a hash's input is `undefined`, which + // `safe_add` turns to NaN and then 0 like any number operator would; + // declining it deoptimized crypto-sha1's whole block loop. + let numbers = typed_numbers( + args, + super::super::vm_numeric_leaf::number_only_leaf_converts_arguments(bytecode), + )?; let value = super::super::vm_numeric_leaf::eval_number_only_leaf( bytecode, &function.params, diff --git a/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs b/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs index 5f25229d..20f38252 100644 --- a/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs +++ b/crates/qjs-runtime/src/bytecode/vm_numeric_leaf.rs @@ -72,6 +72,11 @@ enum NumberOnlyOp { pub(super) struct NumberOnlyProgram { ops: Vec, parameter_slots: Vec, + /// Whether every parameter reaches the result only through an + /// arithmetic or bitwise operator, which applies `ToNumber` first -- so + /// a boolean or `undefined` argument gives the same result as its + /// number. `function id(x) { return x; }` does not qualify. + converts_arguments: bool, } /// Compact, prevalidated form of the straight-line numeric bytecode subset. @@ -512,10 +517,12 @@ impl NumericLeafShortcut { impl NumberOnlyProgram { fn compile(ops: &[FastOp], bytecode: &Bytecode) -> Option { - let (ops, parameter_slots) = compile_number_only_program(ops, bytecode)?; + let (ops, parameter_slots, converts_arguments) = + compile_number_only_program(ops, bytecode)?; Some(Self { ops: registers::lower(&ops)?, parameter_slots, + converts_arguments, }) } @@ -545,10 +552,12 @@ impl NumberOnlyProgram { fn compile_number_only_program( ops: &[FastOp], bytecode: &Bytecode, -) -> Option<(Vec, Vec)> { +) -> Option<(Vec, Vec, bool)> { #[derive(Clone, Copy, PartialEq)] enum StackValue { Number, + /// A parameter's value as passed, not yet through an operator. + Raw, Dead, } @@ -556,9 +565,13 @@ fn compile_number_only_program( return None; } let mut initialized_slots = 0_u32; + let mut raw_slots = 0_u32; for &slot in bytecode.parameter_slots() { initialized_slots |= 1_u32.checked_shl(slot as u32)?; + raw_slots |= 1_u32.checked_shl(slot as u32)?; } + let is_value = + |value: Option| matches!(value, Some(StackValue::Number | StackValue::Raw)); let mut stack = Vec::with_capacity(MAX_FAST_STACK); let mut program = Vec::with_capacity(ops.len()); for (index, op) in ops.iter().enumerate() { @@ -574,39 +587,51 @@ fn compile_number_only_program( FastOp::LoadLocal(slot) if initialized_slots & (1_u32.checked_shl(*slot as u32)?) != 0 => { - stack.push(StackValue::Number); + let raw = raw_slots & (1_u32.checked_shl(*slot as u32)?) != 0; + stack.push(if raw { + StackValue::Raw + } else { + StackValue::Number + }); program.push(NumberOnlyOp::LoadLocal(*slot)); } FastOp::StoreLocal { slot, upvalue_index: None, - } if stack.pop() == Some(StackValue::Number) => { - initialized_slots |= 1_u32.checked_shl(*slot as u32)?; + } if is_value(stack.last().copied()) => { + let bit = 1_u32.checked_shl(*slot as u32)?; + initialized_slots |= bit; + if stack.pop() == Some(StackValue::Raw) { + raw_slots |= bit; + } else { + raw_slots &= !bit; + } program.push(NumberOnlyOp::StoreLocal(*slot)); } FastOp::Binary(op) - if matches!( - (stack.pop(), stack.pop()), - (Some(StackValue::Number), Some(StackValue::Number)) - ) && number_binary(0.0, *op, 0.0).is_some() => + if is_value(stack.pop()) + && is_value(stack.pop()) + && number_binary(0.0, *op, 0.0).is_some() => { stack.push(StackValue::Number); program.push(NumberOnlyOp::Binary(*op)); } FastOp::BinaryConstRight(op, right) - if stack.pop() == Some(StackValue::Number) - && number_binary(0.0, *op, *right).is_some() => + if is_value(stack.pop()) && number_binary(0.0, *op, *right).is_some() => { stack.push(StackValue::Number); program.push(NumberOnlyOp::BinaryConstRight(*op, *right)); } FastOp::Return - if stack.pop() == Some(StackValue::Number) - && stack.iter().all(|value| *value == StackValue::Dead) + if is_value(stack.last().copied()) + && stack[..stack.len() - 1] + .iter() + .all(|value| *value == StackValue::Dead) && index + 1 == ops.len() => { + let converts = stack.pop() == Some(StackValue::Number); program.push(NumberOnlyOp::Return); - return Some((program, bytecode.parameter_slots().to_vec())); + return Some((program, bytecode.parameter_slots().to_vec(), converts)); } _ => return None, } @@ -978,6 +1003,15 @@ impl FastValue { /// body whose plan is not built yet answers `false` and builds it on the /// general evaluator's first visit. #[inline(always)] +/// Whether the number-only program takes any argument by `ToNumber` +/// (`NumberOnlyProgram::converts_arguments`). +pub(super) fn number_only_leaf_converts_arguments(bytecode: &Bytecode) -> bool { + matches!( + bytecode.numeric_leaf_plan.get(), + Some(Some(NumericLeafPlan::NumberOnly(program))) if program.converts_arguments + ) +} + pub(super) fn has_number_only_leaf(bytecode: &Bytecode) -> bool { matches!( bytecode.numeric_leaf_plan.get(), diff --git a/crates/qjs-runtime/src/tests/numeric_loops.rs b/crates/qjs-runtime/src/tests/numeric_loops.rs index fcfd6e02..3f6dd28f 100644 --- a/crates/qjs-runtime/src/tests/numeric_loops.rs +++ b/crates/qjs-runtime/src/tests/numeric_loops.rs @@ -443,3 +443,38 @@ out.join(',');"#; )) ); } + +/// A number-only helper takes a boolean or `undefined` argument by +/// `ToNumber` when every parameter reaches its result through an operator +/// (`safe_add(s, w[j])` past the end of `w`), and never when one is +/// returned as passed (`id`, `keep`). Expected values from V8. +#[test] +fn number_only_helpers_convert_arguments_only_through_operators() { + let source = r#"function safe_add(x, y) { var lsw = (x & 0xFFFF) + (y & 0xFFFF); var msw = (x >> 16) + (y >> 16) + (lsw >> 16); return (msw << 16) | (lsw & 0xFFFF); } +function id(x) { return x; } +function keep(x) { var y = x; return y; } +function plus(x, y) { return x + y; } +function run(n) { + var w = [1, 2, 3], out = [], s = 0; + for (var j = 0; j < n; j++) { s = safe_add(s, w[j]); } + out.push(s); + var r = []; for (var j = 0; j < 4; j++) r.push(String(id(w[j]))); + out.push(r.join('/')); + r = []; for (var j = 0; j < 4; j++) r.push(String(keep(w[j]))); + out.push(r.join('/')); + r = []; for (var j = 0; j < 4; j++) r.push(String(plus(w[j], 1))); + out.push(r.join('/')); + var b = [true, false, undefined, 2]; s = 0; + for (var j = 0; j < 4; j++) s = safe_add(s, b[j]); out.push(s); + return out.join(','); +} +run(6);"#; + assert_eq!( + eval(source), + Ok(Value::String( + "6,1/2/3/undefined,1/2/3/undefined,2/3/4/NaN,3" + .to_owned() + .into() + )) + ); +} From 66826b70ee5dc6bb6acb15791424ac40acf732f0 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 02:51:20 -0700 Subject: [PATCH 3/3] Record the perf24 units, rejections and stack run in T033 Co-Authored-By: Claude Opus 5.5 (1M context) --- tasks/T033-wide-tier-interpreter-exits.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tasks/T033-wide-tier-interpreter-exits.md b/tasks/T033-wide-tier-interpreter-exits.md index fb7d396a..d2f5d2e0 100644 --- a/tasks/T033-wide-tier-interpreter-exits.md +++ b/tasks/T033-wide-tier-interpreter-exits.md @@ -342,6 +342,29 @@ Plan and evidence: `tasks/performance-units/wide-tier-interpreter-exits.json` 0.88, fannkuch 0.92. Liveness counts a site's entries only where an operation can stop. Differential fuzz (600 random loops with type changes mid-loop) matches the pass-off build and V8. +- perf24 units (30a93030..7d18c3ff): a numeric helper may call another + helper of its graph (itself included) when its arguments are proven + numbers -- arguments copied to contiguous registers above the body's, the + callee's numeric body run under the same recursion bound, and `settle` + dropping, to a fixed point, any body whose callee is not numeric or does + not return a number: recursive_call_tree 716 -> 370 instructions a call, + 0.566 cycles against main. A number-only closed-form leaf whose + parameters reach the result only through operators takes boolean and + undefined arguments by ToNumber (sha1's `safe_add(e, w[j])` past the end + of `w` deoptimized its block loops; neutral on time). +- Stack run 7d18c3ff vs main 83108837 (30 blocks, cycles, quiet host; + `target/comparison/perf24-7d18c3ff`): external geomean 0.998 against + main (0.772 against QuickJS-NG); sentinels **0.901** against main and + **0.847 against QuickJS-NG** -- every sentinel now at or below + QuickJS-NG (recursive_call_tree 0.682, heterogeneous_property_read + 0.998, prototype_method_call 0.996). +- Rejected (2026-09-26): global-function helper sites (flattening a global + callee at every entry of an inner loop cost sha1 2.4% instructions, and + its deopting call was a local closed-form one); a hand-written + `Value::clone` (bit-test plus bit copy; churn +15% cycles at +1.5% + instructions); borrowing the prototype chain in the creation proof + (binary-trees +0.3% instructions: a RefCell borrow per level costs what + the Rc upgrade did). - perf23 units (c88cbb06..04e0e7aa): the front end measured 2-3x QuickJS-NG (`tools.benchmark.front_end`; 1-8% of many cases' totals): binary operators by precedence climbing (imaging-darkroom's parse -21%