diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 9bbfc54ca..4fbd9c767 100644 --- a/crates/qjs-cli/hot-functions.order +++ b/crates/qjs-cli/hot-functions.order @@ -12,8 +12,9 @@ # budget 0xa00 __RNvMs6_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_9ObjectRef32write_existing_own_data_property # 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 49 +# budget 0xe60 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named +# budget 0x1800 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn12numeric_plan3run +# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 54 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -55,10 +56,15 @@ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truth __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_ +__RNvMs8_NtCsg55jX0GwzBC_3std4pathNtB5_10Components25parse_next_component_back __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named -__RNvNtNtCsg55jX0GwzBC_3std3sys2fs12canonicalize +__RNvNvNtCsg55jX0GwzBC_3std2fs14read_to_string5inner +__RNvMNtCs2IzQ63mrjeP_9addr2line5frameINtB2_9FrameIterINtNtNtCscUtGwbhD4WH_5gimli4read12endian_slice11EndianSliceNtNtBV_9endianity12LittleEndianEE4nextCsg55jX0GwzBC_3std +__RNvNtNtCsg55jX0GwzBC_3std2io5stdio31print_to_buffer_if_capture_used +__RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryINtNtCsl8K0bEFm1U0_4core6result6ResultINtNtBb_4sync3ArcNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev13AbbreviationsENtB25_5ErrorEE10dying_nextCsg55jX0GwzBC_3std +__RNvNtNtNtNtCsg55jX0GwzBC_3std3sys2io5error4unix17decode_error_kind +__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn12numeric_plan3run +__RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtNtNtB9_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RNvMs4_NtCs1OjIl8oxbrv_5alloc7raw_vecINtB5_11RawVecInnerNtNtCsg55jX0GwzBC_3std5alloc6SystemE14grow_amortizedBW_ __RNvXs1i_NtCsl8K0bEFm1U0_4core3fmtRNtNtNtB8_5panic8location8LocationNtB6_7Display3fmtCsg55jX0GwzBC_3std __RNvXs1_NtCs9nYd1Hk1rek_11qjs_runtime5valueNtB5_5ValueNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone diff --git a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs index 89c91775b..fa4e36400 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs @@ -93,6 +93,18 @@ impl WideActivation<'_> { { return Ok(crate::string::js_string_eq(left, right) == (op == BinaryOp::Eq)); } + // The rest that runs no user code -- `node.left == null` -- without + // cloning the operands into the general path. + if let Some(Value::Boolean(result)) = + crate::operations::eval_binary_without_env(left, op, right) + { + return Ok(result); + } + if matches!(op, BinaryOp::Eq | BinaryOp::Ne) + && let Some(equal) = loose_equality_without_conversion(left, right) + { + return Ok(equal == (op == BinaryOp::Eq)); + } let value = self.eval_binary(left.clone(), op, right.clone())?; Ok(crate::is_truthy(&value)) } @@ -1406,6 +1418,22 @@ fn run_frames( } } +/// `left == right` where no conversion can run: `null` or `undefined` +/// against anything -- nothing else equals them but the IsHTMLDDA host +/// object -- and two numbers. Kept out of the shared operator helpers, whose +/// growth re-rolled this tier's dispatch codegen (crypto-md5 +2.5% cycles at +/// identical instructions). +#[inline(never)] +fn loose_equality_without_conversion(left: &Value, right: &Value) -> Option { + match (left, right) { + (Value::Null | Value::Undefined, other) | (other, Value::Null | Value::Undefined) => Some( + matches!(other, Value::Null | Value::Undefined) || crate::html_dda::is_html_dda(other), + ), + (Value::Number(left), Value::Number(right)) => Some(left == right), + _ => None, + } +} + /// A unary operator, with the interpreter's own fast paths: `!` and `void` /// coerce nothing, a number answers inline, everything else runs the general /// operator in an empty realm frame like the tier's binary path. @@ -1499,6 +1527,13 @@ fn construct_from_activation( if let Some(array) = construct_plain_array(env, &callee, arguments) { return Ok(array); } + if let Value::Function(function) = &callee + && function.native.is_some() + && let Some(result) = + crate::function::construct_intrinsic_directly(function, arguments, env) + { + return result; + } let mut env = env.empty_frame(); crate::function::construct_function(callee.clone(), callee, arguments.to_vec(), &mut env) } diff --git a/crates/qjs-runtime/src/bytecode/compiler.rs b/crates/qjs-runtime/src/bytecode/compiler.rs index 427e0766a..5028a6fd5 100644 --- a/crates/qjs-runtime/src/bytecode/compiler.rs +++ b/crates/qjs-runtime/src/bytecode/compiler.rs @@ -166,7 +166,9 @@ pub(super) fn compile_direct_eval_script( direct_eval_source: true, ..Compiler::default() }; - compiler.compile_eval_into(script) + let mut bytecode = compiler.compile_eval_into(script)?; + bytecode.mark_direct_eval_code(); + Ok(bytecode) } /// Compiles a module body. Module code is always strict mode, regardless of a diff --git a/crates/qjs-runtime/src/bytecode/ir.rs b/crates/qjs-runtime/src/bytecode/ir.rs index 67d6af11c..9bed56af2 100644 --- a/crates/qjs-runtime/src/bytecode/ir.rs +++ b/crates/qjs-runtime/src/bytecode/ir.rs @@ -772,6 +772,8 @@ pub struct Bytecode { /// name-keyed binding. Deciding this per local used to ask the environment /// about every name on every call. cached_authoritative_mask_clean: u128, + /// The top level of a direct eval's code (not a function inside it). + direct_eval_code: bool, } impl Bytecode { @@ -940,6 +942,7 @@ impl Bytecode { cached_uses_lexical_this: false, cached_hoisted_slots: Vec::new(), cached_authoritative_mask_clean: 0, + direct_eval_code: false, }; // Order matters: closure/arguments metadata reads the simpler caches // (written-binding names, creates-closures) computed just above. Nested @@ -1421,6 +1424,29 @@ impl Bytecode { self.cached_contains_direct_eval } + pub(super) fn mark_direct_eval_code(&mut self) { + self.direct_eval_code = true; + } + + /// Whether this is the top level of a direct eval's code. + pub(super) fn is_direct_eval_code(&self) -> bool { + self.direct_eval_code + } + + /// Every name the body resolves through its environment at run time + /// rather than through a slot: its free-name reads, writes and `typeof`s, + /// and its `delete`s by name. Nested function bodies' names are included + /// only through `global_names`. + pub(super) fn names_resolved_by_name(&self) -> impl Iterator { + self.global_names + .iter() + .map(String::as_str) + .chain(self.code.iter().filter_map(|op| match op { + Op::DeleteIdent(name) | Op::DeleteIdentWith { name, .. } => Some(name.as_str()), + _ => None, + })) + } + pub(crate) fn contains_with(&self) -> bool { self.cached_contains_with } diff --git a/crates/qjs-runtime/src/bytecode/mod.rs b/crates/qjs-runtime/src/bytecode/mod.rs index 953df1634..9dd821c1a 100644 --- a/crates/qjs-runtime/src/bytecode/mod.rs +++ b/crates/qjs-runtime/src/bytecode/mod.rs @@ -86,6 +86,7 @@ pub(crate) use vm_numeric_leaf::try_eval_numeric_leaf; pub(crate) use vm_private::apply_instance_private_element; pub(crate) use vm_result::FunctionBytecodeResult; pub(crate) use vm_set::set_property as set_object_property; +pub(crate) use vm_string_append::concat_primitives; pub(crate) use vm_this_property_leaf::try_eval_this_property_leaf; pub(crate) fn delete_object_property( diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/branchy_nested_tests.rs b/crates/qjs-runtime/src/bytecode/typed_loop/branchy_nested_tests.rs index e217dcfe2..735f96ece 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/branchy_nested_tests.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/branchy_nested_tests.rs @@ -413,3 +413,21 @@ fn an_inherited_accessor_is_not_answered_from_the_slot_cache() { run(o, 5) + ':' + calls;"; assert_eq!(eval(source), Ok(Value::String("10:5".into()))); } + +/// A relational comparison against a string literal in a typed loop +/// compares strings by code unit (and numbers as numbers) instead of +/// unboxing; a mixed or object operand leaves the loop exactly, `valueOf` +/// included. Expected values from V8. +#[test] +fn typed_loops_compare_against_string_literals() { + let source = r#"function scan(s) { var bad = 0, le = 0; for (var i = 0; i < s.length; i++) { var ch = s.charAt(i); if (ch < "0" || ch > "9") bad++; if (ch <= "5") le++; if (ch >= "x") le += 10; } return bad + ":" + le; } +function mixed(a) { var n = 0; for (var i = 0; i < a.length; i++) { if (a[i] < "5") n++; if (a[i] > 3) n += 100; } return n; } +var calls = 0; var o = { valueOf: function () { calls++; return 4; } }; +function objs(a) { var n = 0; for (var i = 0; i < a.length; i++) { if (a[i] < "5") n++; } return n; } +var out = [scan("1234xyz"), scan("12éz9"), scan(""), mixed(["1", "9", 2, 7, "10", undefined, null]), objs(["1", o, "7", o]), calls]; +out.join(',');"#; + assert_eq!( + eval(source), + Ok(Value::String("3:34,2:22,0:0,304,3,2".to_owned().into())) + ); +} diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs b/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs index d556dc04d..5f41a86a2 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs @@ -391,6 +391,7 @@ struct Builder<'a> { mod admission; mod element; +mod globals; pub(super) use admission::{admitted_binary, admitted_unary}; use admission::{expression_has_control_flow, scalar_expression_may_write_or_branch}; @@ -661,6 +662,19 @@ impl<'a> Builder<'a> { /// /// Two scalars stay on the scalar comparison: widening them would trade a /// register compare for two `Box` operations and a value comparison. + fn top_operands_include_string_constant(&self) -> bool { + self.stack + .iter() + .rev() + .take(2) + .any(|&(register, class, _)| { + class == Class::Boxed + && self.boxed_constants.iter().any(|(candidate, value)| { + *candidate == register && matches!(value, Value::String(_)) + }) + }) + } + fn top_operands_include_boxed(&self) -> bool { self.stack .iter() @@ -1022,83 +1036,6 @@ impl<'a> Builder<'a> { Some(()) } - /// Boxed register seeded from the global binding `name`, allocating one if - /// new. - fn global_register(&mut self, name: &str) -> Option { - if let Some((register, _)) = self - .boxed_global_reads - .iter() - .find(|(_, candidate)| candidate == name) - { - return Some(*register); - } - let register = self.fresh_boxed()?; - self.boxed_global_reads.push((register, name.to_owned())); - Some(register) - } - - /// Whether the region contains an instruction that could write `name`, - /// which would make a hoisted read of that same binding observably stale. - fn region_writes_global_named(&self, name: &str) -> bool { - self.bytecode.code[self.header..=self.backedge] - .iter() - .any(|op| match op { - Op::StoreGlobalStrict(candidate) | Op::DefineGlobalVar(candidate) => { - candidate == name - } - Op::StoreGlobalSloppy { - name: candidate, .. - } - | Op::StoreLocalOrGlobalSloppy { - name: candidate, .. - } - | Op::AppendStringLiteralGlobal { - name: candidate, .. - } => candidate == name, - _ => false, - }) - } - - /// A named-property write could target `globalThis` and mutate a hoisted - /// global binding. A region with a sloppy fallback sink therefore keeps - /// those writes out of this tier; dense array writes remain separately - /// guarded and cannot alter a global object's binding descriptors. - fn region_writes_a_sloppy_global(&self) -> bool { - self.bytecode.code[self.header..=self.backedge] - .iter() - .any(|op| matches!(op, Op::StoreLocalOrGlobalSloppy { .. })) - } - - /// Returns the frame slot for an unresolved sloppy binding only when this - /// bytecode owns the compiler-emitted fallback slot for the same name. - fn sloppy_global_fallback_slot(&self, name: &str) -> Option { - let slot = self.bytecode.local_slot(name)?; - let local = self.bytecode.locals.get(slot)?; - (local.name == name && local.sloppy_global_fallback && !local.compiler_temporary) - .then_some(slot) - } - - /// Index of the prepared sink for a sloppy fallback write, adding it once - /// per slot/name pair. The runtime validates the dynamic binding and - /// property identities before entering the program. - fn sloppy_global_write_index(&mut self, slot: usize, name: &str) -> Option { - if self.sloppy_global_fallback_slot(name) != Some(slot) { - return None; - } - let slot = u32::try_from(slot).ok()?; - if let Some(index) = - self.sloppy_global_writes - .iter() - .position(|(candidate_slot, candidate_name)| { - *candidate_slot == slot && candidate_name == name - }) - { - return u16::try_from(index).ok(); - } - self.sloppy_global_writes.push((slot, name.to_owned())); - u16::try_from(self.sloppy_global_writes.len() - 1).ok() - } - /// Diagnostic builds name the instruction a pass gave up on: /// `QJS_TL_TRACE=1` on a `perf-counters` binary prints the region, the /// site, the operation and what this pass discovered. Paired with the @@ -1407,11 +1344,20 @@ impl<'a> Builder<'a> { // deoptimized on its first iteration instead. This arm must come // first, and must require a genuinely boxed operand, so ordinary // numeric comparisons keep the scalar file. + // Relational operators likewise when one side is a string + // literal: `ch < "0"` over a `charAt` result compares strings, + // which unboxing deoptimized. Only then -- a boxed `i < + // list.length` must keep the pass that reads the length as a + // scalar. Op::Binary(binary) - if matches!( + if (matches!( *binary, BinaryOp::Eq | BinaryOp::Ne | BinaryOp::StrictEq | BinaryOp::StrictNe - ) && self.top_operands_include_boxed() => + ) && self.top_operands_include_boxed()) + || (matches!( + *binary, + BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge + ) && self.top_operands_include_string_constant()) => { let (right, _) = self.pop_boxed()?; let (left, _) = self.pop_boxed()?; diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/compile/globals.rs b/crates/qjs-runtime/src/bytecode/typed_loop/compile/globals.rs new file mode 100644 index 000000000..5e5fa59e2 --- /dev/null +++ b/crates/qjs-runtime/src/bytecode/typed_loop/compile/globals.rs @@ -0,0 +1,83 @@ +//! The region's global bindings: hoisted reads, and the sloppy fallback +//! globals it writes. + +use super::{Builder, Op}; + +impl Builder<'_> { + /// Boxed register seeded from the global binding `name`, allocating one if + /// new. + pub(super) fn global_register(&mut self, name: &str) -> Option { + if let Some((register, _)) = self + .boxed_global_reads + .iter() + .find(|(_, candidate)| candidate == name) + { + return Some(*register); + } + let register = self.fresh_boxed()?; + self.boxed_global_reads.push((register, name.to_owned())); + Some(register) + } + + /// Whether the region contains an instruction that could write `name`, + /// which would make a hoisted read of that same binding observably stale. + pub(super) fn region_writes_global_named(&self, name: &str) -> bool { + self.bytecode.code[self.header..=self.backedge] + .iter() + .any(|op| match op { + Op::StoreGlobalStrict(candidate) | Op::DefineGlobalVar(candidate) => { + candidate == name + } + Op::StoreGlobalSloppy { + name: candidate, .. + } + | Op::StoreLocalOrGlobalSloppy { + name: candidate, .. + } + | Op::AppendStringLiteralGlobal { + name: candidate, .. + } => candidate == name, + _ => false, + }) + } + + /// A named-property write could target `globalThis` and mutate a hoisted + /// global binding. A region with a sloppy fallback sink therefore keeps + /// those writes out of this tier; dense array writes remain separately + /// guarded and cannot alter a global object's binding descriptors. + pub(super) fn region_writes_a_sloppy_global(&self) -> bool { + self.bytecode.code[self.header..=self.backedge] + .iter() + .any(|op| matches!(op, Op::StoreLocalOrGlobalSloppy { .. })) + } + + /// Returns the frame slot for an unresolved sloppy binding only when this + /// bytecode owns the compiler-emitted fallback slot for the same name. + pub(super) fn sloppy_global_fallback_slot(&self, name: &str) -> Option { + let slot = self.bytecode.local_slot(name)?; + let local = self.bytecode.locals.get(slot)?; + (local.name == name && local.sloppy_global_fallback && !local.compiler_temporary) + .then_some(slot) + } + + /// Index of the prepared sink for a sloppy fallback write, adding it once + /// per slot/name pair. The runtime validates the dynamic binding and + /// property identities before entering the program. + pub(super) fn sloppy_global_write_index(&mut self, slot: usize, name: &str) -> Option { + if self.sloppy_global_fallback_slot(name) != Some(slot) { + return None; + } + let slot = u32::try_from(slot).ok()?; + if let Some(index) = + self.sloppy_global_writes + .iter() + .position(|(candidate_slot, candidate_name)| { + *candidate_slot == slot && candidate_name == name + }) + { + return u16::try_from(index).ok(); + } + self.sloppy_global_writes.push((slot, name.to_owned())); + u16::try_from(self.sloppy_global_writes.len() - 1).ok() + } +} diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs b/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs index 773f4479c..a1c49eb91 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/execute.rs @@ -1615,12 +1615,45 @@ fn boxed_equality(op: BinaryOp, left: &Value, right: &Value) -> Option { (is_object_like(left) && is_object_like(right)).then(|| fast_reference_eq(left, right)) } BinaryOp::StrictEq | BinaryOp::StrictNe => fast_strict_eq(left, right), + BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => { + return boxed_relation(op, left, right); + } _ => None, }?; let negated = matches!(op, BinaryOp::Ne | BinaryOp::StrictNe); Some(Typed::Boolean(equal != negated)) } +/// A relational comparison of two strings (by code unit) or two numbers; +/// anything else -- an object's `valueOf` could run -- deoptimizes. +/// `ch < "0"` over a `charAt` result had unboxed the string and +/// deoptimized every entry of string-validate-input's scanning loops. +#[inline(never)] +fn boxed_relation(op: BinaryOp, left: &Value, right: &Value) -> Option { + use std::cmp::Ordering; + let holds = match (left, right) { + (Value::String(left), Value::String(right)) => { + let ordering = crate::string::js_string_cmp(left, right); + match op { + BinaryOp::Lt => ordering == Ordering::Less, + BinaryOp::Le => ordering != Ordering::Greater, + BinaryOp::Gt => ordering == Ordering::Greater, + BinaryOp::Ge => ordering != Ordering::Less, + _ => return None, + } + } + (Value::Number(left), Value::Number(right)) => match op { + BinaryOp::Lt => left < right, + BinaryOp::Le => left <= right, + BinaryOp::Gt => left > right, + BinaryOp::Ge => left >= right, + _ => return None, + }, + _ => return None, + }; + Some(Typed::Boolean(holds)) +} + #[inline(always)] pub(super) fn typed_unary(op: UnaryOp, argument: Typed) -> Option { if let UnaryOp::Not = op { diff --git a/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs b/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs index 230efe60b..1ad9889f6 100644 --- a/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs +++ b/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs @@ -193,7 +193,13 @@ impl Vm<'_> { } else { None }; - let deopt_bindings = self.frame_deopt_bindings_memoized(); + let deopt_bindings = self.frame_deopt_bindings_memoized().filter(|bindings| { + self.closure_needs_dynamic_scope( + bytecode, + *lexical_this || *lexical_arguments, + bindings, + ) + }); let function = Function::new_user_compiled(CompiledUserFunction { name: name.clone(), has_name_binding: *has_name_binding, diff --git a/crates/qjs-runtime/src/bytecode/vm_frame_init.rs b/crates/qjs-runtime/src/bytecode/vm_frame_init.rs index 258d4d464..7a03625c9 100644 --- a/crates/qjs-runtime/src/bytecode/vm_frame_init.rs +++ b/crates/qjs-runtime/src/bytecode/vm_frame_init.rs @@ -386,6 +386,41 @@ impl<'a> Vm<'a> { } } + /// Whether a function literal this frame evaluates must resolve names + /// through the frame's dynamic scope `bindings`, or can be created + /// without it -- and so be called through the slot-seeded direct path + /// and the compact tiers, which host no dynamic scope. + /// + /// It can when nothing it resolves by name is in that scope now and + /// nothing can put it there later. The frame is a direct eval's top-level + /// code, whose scope is a fork only that code writes; without an `eval` + /// or `with` of its own, that code adds no name after the closure exists + /// (its `var`s are in the scope from the start). The closure itself must + /// not reach the scope another way: no `eval` or `with`, no nested + /// function to hand it on, and its own `this` and `arguments`. + /// `format0 = function () { return this.getFullYear() + ... }` in + /// date-format-xparb is the case: every call built a full interpreter + /// frame to find `String` in the global object. + pub(super) fn closure_needs_dynamic_scope( + &self, + closure: &Bytecode, + inherits_frame: bool, + bindings: &DynamicBindings, + ) -> bool { + let frame: &Bytecode = &self.bytecode; + !frame.is_direct_eval_code() + || frame.contains_direct_eval() + || frame.contains_with() + || !self.with_stack().is_empty() + || inherits_frame + || closure.contains_direct_eval() + || closure.contains_with() + || closure.creates_closures() + || closure + .names_resolved_by_name() + .any(|name| name != "this" && bindings.contains_key(name)) + } + /// `frame_deopt_bindings` for a frame that asks repeatedly -- every /// closure a body with a direct `eval` creates. The overlay is skipped /// when it would change nothing: the environment is the one last diff --git a/crates/qjs-runtime/src/bytecode/vm_string_append.rs b/crates/qjs-runtime/src/bytecode/vm_string_append.rs index 65177b551..e119a7aa3 100644 --- a/crates/qjs-runtime/src/bytecode/vm_string_append.rs +++ b/crates/qjs-runtime/src/bytecode/vm_string_append.rs @@ -419,10 +419,7 @@ fn is_plain_primitive(value: &Value) -> bool { /// when nothing else holds it -- the intermediate of `a + b + c` is such a /// buffer -- and borrowing the right operand. Hands both operands back /// otherwise. -pub(in crate::bytecode) fn concat_primitives( - left: Value, - right: Value, -) -> Result { +pub(crate) fn concat_primitives(left: Value, right: Value) -> Result { if !(matches!(left, Value::String(_)) || matches!(right, Value::String(_))) || !is_plain_primitive(&left) || !is_plain_primitive(&right) diff --git a/crates/qjs-runtime/src/conversion.rs b/crates/qjs-runtime/src/conversion.rs index d9ac47198..ad28dec28 100644 --- a/crates/qjs-runtime/src/conversion.rs +++ b/crates/qjs-runtime/src/conversion.rs @@ -222,7 +222,9 @@ pub(crate) fn to_primitive_with_hint( if !is_object_like(&value) { return Ok(value); } - if let Some(symbol) = symbol::to_primitive_symbol(env) { + if let Some(symbol) = symbol::to_primitive_symbol(env) + && !ordinary_chain_lacks_symbol(&value, &symbol) + { let method = property_value_key(value.clone(), &PropertyKey::Symbol(symbol), env)?; if !matches!(method, Value::Undefined | Value::Null) { if !is_callable(&method) { @@ -250,6 +252,26 @@ pub(crate) fn to_primitive_with_hint( ordinary_to_primitive(value, hint, env) } +/// Whether an ordinary object and every object on its prototype chain lack +/// an own `symbol` property, so reading it is `undefined` without running +/// code. `false` as soon as the chain leaves ordinary objects. +fn ordinary_chain_lacks_symbol(value: &Value, symbol: &crate::ObjectRef) -> bool { + let Value::Object(object) = value else { + return false; + }; + let mut current = object.clone(); + loop { + if current.own_symbol_property(symbol).is_some() { + return false; + } + match current.prototype_slot() { + None => return true, + Some(crate::Prototype::Object(next)) => current = next, + Some(_) => return false, + } + } +} + pub(crate) fn ordinary_to_primitive( value: Value, hint: PreferredType, @@ -307,7 +329,7 @@ fn intrinsic_string_wrapper_primitive( PreferredType::String => "toString", PreferredType::Number | PreferredType::Default => "valueOf", }; - if object.own_property(first).is_some() { + if object.has_own_property(first) { return None; } let method = object.ordinary_prototype()?.own_property(first)?; diff --git a/crates/qjs-runtime/src/date/value.rs b/crates/qjs-runtime/src/date/value.rs index 2d79e3e8e..670f44eb2 100644 --- a/crates/qjs-runtime/src/date/value.rs +++ b/crates/qjs-runtime/src/date/value.rs @@ -26,8 +26,14 @@ pub(super) fn parse_date_string(source: &str) -> f64 { } pub(super) fn date_value(this_value: Value) -> Result { - let object = date_object(this_value)?; - date_value_from_object(&object) + // One lookup of the time value, which is also the Date check. + let Value::Object(object) = &this_value else { + return Err(RuntimeError { + thrown: None, + message: "Date method receiver is not an object".to_owned(), + }); + }; + date_value_from_object(object) } pub(super) fn date_value_from_object(object: &ObjectRef) -> Result { diff --git a/crates/qjs-runtime/src/function.rs b/crates/qjs-runtime/src/function.rs index c2e19e6f8..833ce8bea 100644 --- a/crates/qjs-runtime/src/function.rs +++ b/crates/qjs-runtime/src/function.rs @@ -4,6 +4,7 @@ mod call_frame; mod env; mod eval_cache; mod install; +mod intrinsic_construct; mod local_names; mod native_kind; mod prototype; @@ -40,6 +41,7 @@ pub(crate) use call::{ initialize_instance_fields, is_direct_construct_class, is_direct_leaf_function, try_construct_direct_leaf_function, }; +pub(crate) use intrinsic_construct::construct_intrinsic_directly; pub(crate) fn is_call_frame_binding(name: &str) -> bool { matches!( name, diff --git a/crates/qjs-runtime/src/function/call.rs b/crates/qjs-runtime/src/function/call.rs index a6e5886e8..706452f3c 100644 --- a/crates/qjs-runtime/src/function/call.rs +++ b/crates/qjs-runtime/src/function/call.rs @@ -843,6 +843,13 @@ pub(crate) fn construct_function( }; return construct_function(real_target, forwarded_new_target, combined_arguments, env); } + if let Value::Function(function) = &target + && function.native.is_some() + && target.same_value(&new_target) + && let Some(result) = super::construct_intrinsic_directly(function, &argument_values, env) + { + return result; + } ensure_constructor(&target)?; ensure_constructor(&new_target)?; diff --git a/crates/qjs-runtime/src/function/intrinsic_construct.rs b/crates/qjs-runtime/src/function/intrinsic_construct.rs new file mode 100644 index 000000000..6a8c7cbd0 --- /dev/null +++ b/crates/qjs-runtime/src/function/intrinsic_construct.rs @@ -0,0 +1,30 @@ +//! `new C(...)` on a built-in constructor, with `new.target` the constructor +//! itself, whose result can be built in one step. The general construct path +//! reads `C.prototype`, allocates an ordinary receiver and calls the native +//! with it; for these constructors `prototype` is non-writable and +//! non-configurable and the arguments below cannot run user code, so none of +//! those steps is observable. + +use crate::{CallEnv, Function, NativeFunction, RuntimeError, Value}; + +/// The constructed object, or `None` for any other constructor or argument +/// shape, which takes the general path. The caller guarantees `new.target` +/// is `function`. +pub(crate) fn construct_intrinsic_directly( + function: &Function, + argument_values: &[Value], + env: &CallEnv, +) -> Option> { + if function.bound.is_some() { + return None; + } + match function.native? { + NativeFunction::RegExp => { + crate::regexp::construct_regexp_from_strings(function, argument_values, env) + } + NativeFunction::String => { + crate::string::construct_string_wrapper(function, argument_values, env).map(Ok) + } + _ => None, + } +} diff --git a/crates/qjs-runtime/src/html_dda.rs b/crates/qjs-runtime/src/html_dda.rs index 5555f8779..15dbd2e4d 100644 --- a/crates/qjs-runtime/src/html_dda.rs +++ b/crates/qjs-runtime/src/html_dda.rs @@ -34,6 +34,26 @@ mod tests { ); } + /// `x == null` in a compiled function answers without the general + /// operator, and still treats IsHTMLDDA as `undefined`. + #[test] + fn loose_null_comparison_in_a_function_keeps_is_html_dda() { + assert_eq!( + eval( + "function eq(a, b) { var n = 0; if (a == b) n += 1; if (a != b) n += 10; return n; } + var v = __quickjsRustIsHTMLDDA, o = {}, out = []; + for (var i = 0; i < 3; i++) { + out.push(eq(v, null), eq(undefined, v), eq(o, null), eq(null, o), eq(0, null), + eq('', undefined), eq(null, undefined), eq(1, 1), eq(NaN, NaN), eq(0, -0)); + } + out.join(':');" + ), + Ok(Value::String( + "1:1:10:10:10:10:1:1:10:1:".repeat(3).trim_end_matches(':').into() + )) + ); + } + #[test] fn no_property_forges_is_html_dda() { assert_eq!( diff --git a/crates/qjs-runtime/src/operations.rs b/crates/qjs-runtime/src/operations.rs index 5c24bd6a5..8c03efba5 100644 --- a/crates/qjs-runtime/src/operations.rs +++ b/crates/qjs-runtime/src/operations.rs @@ -62,6 +62,10 @@ pub(crate) fn eval_binary( BinaryOp::Add => { let left = to_primitive_with_env(left, env)?; let right = to_primitive_with_env(right, env)?; + let (left, right) = match crate::bytecode::concat_primitives(left, right) { + Ok(concatenated) => return Ok(concatenated), + Err(operands) => operands, + }; if matches!(left, Value::String(_)) || matches!(right, Value::String(_)) { // Reuse the left operand's allocation rather than building a // fresh `len(left) + len(right)` buffer each time. This turns a diff --git a/crates/qjs-runtime/src/regexp.rs b/crates/qjs-runtime/src/regexp.rs index 38bf9ce0e..6f86a5f0b 100644 --- a/crates/qjs-runtime/src/regexp.rs +++ b/crates/qjs-runtime/src/regexp.rs @@ -238,6 +238,62 @@ pub(crate) fn native_regexp( Ok(Value::Object(object)) } +thread_local! { + /// The keys every RegExp object carries, shared so building one costs no + /// key allocations. Read-only after first use. + static REGEXP_OBJECT_KEYS: [std::rc::Rc; 3] = [ + REGEXP_SOURCE_PROPERTY.into(), + REGEXP_FLAGS_PROPERTY.into(), + "lastIndex".into(), + ]; +} + +/// `new RegExp(pattern, flags)` with `new.target` the constructor itself and +/// string (or absent) arguments -- every regular-expression literal takes +/// this shape. Neither argument can be a RegExp or run user code, and +/// `RegExp.prototype` is non-writable and non-configurable, so the object is +/// built directly from the argument strings. `None` for any other shape. +pub(crate) fn construct_regexp_from_strings( + function: &Function, + argument_values: &[Value], + env: &CallEnv, +) -> Option> { + let Some(Value::String(source)) = argument_values.first() else { + return None; + }; + let flags = match argument_values.get(1) { + Some(Value::String(flags)) => flags.clone(), + None | Some(Value::Undefined) => JsString::default(), + Some(_) => return None, + }; + let validated = env + .realm() + .regexp_programs() + .borrow_mut() + .is_validated_strings(source, &flags); + if !validated && let Err(error) = validate_regexp_init_cached(source, &flags, env) { + return Some(Err(error)); + } + let mut builder = crate::value::OrderedDataPropertyBuilder::with_capacity(3); + REGEXP_OBJECT_KEYS.with(|[source_key, flags_key, last_index_key]| { + builder.insert_property( + source_key.clone(), + Property::non_enumerable(Value::String(source.clone())), + ); + builder.insert_property( + flags_key.clone(), + Property::non_enumerable(Value::String(flags)), + ); + builder.insert_property( + last_index_key.clone(), + Property::data(Value::Number(0.0), false, true, false), + ); + }); + Some(Ok(Value::Object( + builder.finish(function_prototype(function)), + ))) +} + pub(crate) fn regexp_literal_value( source: &str, flags: &str, diff --git a/crates/qjs-runtime/src/regexp/program_cache.rs b/crates/qjs-runtime/src/regexp/program_cache.rs index 5c20da04b..69759a36b 100644 --- a/crates/qjs-runtime/src/regexp/program_cache.rs +++ b/crates/qjs-runtime/src/regexp/program_cache.rs @@ -12,6 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use super::matcher::PreparedRegexp; +use crate::JsString; /// Distinct patterns kept per table before the table is cleared. const MAX_ENTRIES: usize = 64; @@ -23,6 +24,10 @@ pub(crate) struct ProgramCache { programs: [HashMap, Rc>; 16], /// Sources that passed pattern validation, by their complete flags text. validated: HashMap, HashSet>>, + /// The last pair validated through `is_validated_strings`: a literal in a + /// loop passes the same constant strings every time, so this answers it + /// with a pointer comparison instead of hashing the whole pattern. + last_validated: Option<(JsString, JsString)>, } impl ProgramCache { @@ -61,6 +66,20 @@ impl ProgramCache { .is_some_and(|sources| sources.contains(source)) } + pub(super) fn is_validated_strings(&mut self, source: &JsString, flags: &JsString) -> bool { + if let Some((last_source, last_flags)) = &self.last_validated + && (JsString::ptr_eq(last_source, source) || last_source.as_str() == source.as_str()) + && last_flags.as_str() == flags.as_str() + { + return true; + } + if !self.is_validated(source, flags) { + return false; + } + self.last_validated = Some((source.clone(), flags.clone())); + true + } + pub(super) fn mark_validated(&mut self, source: &str, flags: &str) { if self.validated.len() >= MAX_ENTRIES { self.validated.clear(); diff --git a/crates/qjs-runtime/src/string.rs b/crates/qjs-runtime/src/string.rs index 88951f28d..1fe9f8467 100644 --- a/crates/qjs-runtime/src/string.rs +++ b/crates/qjs-runtime/src/string.rs @@ -10,7 +10,7 @@ mod prototype; pub(crate) use object_keys::StringObjectKeys; -pub(crate) use constructor::define_string_data; +pub(crate) use constructor::{construct_string_wrapper, define_string_data}; pub(super) use constructor::{ is_string_object, native_string, native_string_from_char_code, native_string_from_code_point, native_string_raw, string_from_code_point_numbers, string_object_value, diff --git a/crates/qjs-runtime/src/string/constructor.rs b/crates/qjs-runtime/src/string/constructor.rs index 3e02443f9..102fa1881 100644 --- a/crates/qjs-runtime/src/string/constructor.rs +++ b/crates/qjs-runtime/src/string/constructor.rs @@ -36,6 +36,32 @@ pub(crate) fn native_string( Ok(Value::Object(object)) } +/// `new String(value)` for a string or number `value`, whose conversion +/// runs no user code, built directly (see +/// `function::construct_intrinsic_directly`). +pub(crate) fn construct_string_wrapper( + function: &Function, + argument_values: &[Value], + env: &CallEnv, +) -> Option { + let text = match argument_values.first()? { + Value::String(text) => text.clone(), + Value::Number(number) => crate::JsString::from(crate::number::number_to_js_string(*number)), + _ => return None, + }; + let keys = env.realm().string_object_keys(); + let mut builder = crate::value::OrderedDataPropertyBuilder::with_capacity(2); + let length = crate::string::js_string_code_unit_len(&text) as f64; + builder.insert_property(keys.data(), Property::non_enumerable(Value::String(text))); + builder.insert_property( + keys.length(), + Property::data(Value::Number(length), false, false, false), + ); + let object = builder.finish(function_prototype(function)); + object.defer_string_indices(); + Some(Value::Object(object)) +} + pub(crate) fn native_string_from_char_code( argument_values: &[Value], env: &mut CallEnv, diff --git a/crates/qjs-runtime/src/tests/direct_eval.rs b/crates/qjs-runtime/src/tests/direct_eval.rs index 511b68ef2..273aef229 100644 --- a/crates/qjs-runtime/src/tests/direct_eval.rs +++ b/crates/qjs-runtime/src/tests/direct_eval.rs @@ -31,3 +31,32 @@ fn a_read_only_direct_eval_leaves_its_caller_current() { )) ); } + +/// A function literal in eval code that resolves nothing in the eval's +/// scope is created without it; one that does, or that could see a later +/// declaration, keeps it. +#[test] +fn eval_created_closures_resolve_the_same_names_with_or_without_the_eval_scope() { + assert_eq!( + eval( + "var out = []; + function outer(a) { var local = 'L'; eval('var g = function () { return local + a + typeof String; }'); return g(); } + function own() { eval('var y = 3; var g = function () { return y; }; y = 4;'); return g(); } + function receiver() { eval('var g = function () { return this === globalThis ? \"G\" : this.v; }'); return g() + g.call({ v: 'o' }); } + function args() { eval('var g = function () { return arguments.length + \":\" + arguments[0]; }'); return g(7, 8); } + function later() { eval('var g = function () { return typeof q; }; eval(\"var q = 1\");'); return g(); } + function writes() { var x = 1; eval('var g = function () { x = 2; return x; }'); return g() + x; } + function nested() { eval('var g = (function () { return function () { return String.name; }; })();'); return g(); } + for (var i = 0; i < 3; i++) { + out.push(outer('A'), own(), receiver(), args(), later(), writes(), nested()); + } + out.join();" + ), + Ok(Value::String( + "LAfunction,4,Go,2:7,number,4,String," + .repeat(3) + .trim_end_matches(',') + .into() + )) + ); +} diff --git a/crates/qjs-runtime/src/tests/regexp_program_cache.rs b/crates/qjs-runtime/src/tests/regexp_program_cache.rs index 61028f4a0..aa811cb4f 100644 --- a/crates/qjs-runtime/src/tests/regexp_program_cache.rs +++ b/crates/qjs-runtime/src/tests/regexp_program_cache.rs @@ -83,3 +83,32 @@ fn replace_shares_the_program_with_exec() { Ok(string("a[1]b[22]c[333]|44|q<5>")) ); } + +#[test] +fn string_argument_construction_matches_the_general_path() { + assert_eq!( + eval( + "function build() { + var out = []; + for (var i = 0; i < 3; i++) { + var literal = /x+/g, direct = new RegExp('x+', 'g'); + var noFlags = new RegExp('y'), bound = new (RegExp.bind(null, 'z'))('i'); + out.push(literal.source + literal.flags + literal.lastIndex, + direct.source + direct.flags, + noFlags.flags === '' && Object.getPrototypeOf(noFlags) === RegExp.prototype, + bound.source + bound.flags, + Object.getOwnPropertyNames(direct).join('|'), + literal !== /x+/g); + try { new RegExp('(', ''); } catch (e) { out.push(e.name); } + } + return out.join(); + } + build();" + ), + Ok(string( + "x+g0,x+g,true,zi,lastIndex,true,SyntaxError," + .repeat(3) + .trim_end_matches(',') + )) + ); +} diff --git a/crates/qjs-runtime/src/tests/strings/constructor.rs b/crates/qjs-runtime/src/tests/strings/constructor.rs index e03a8d0f1..b764dec4f 100644 --- a/crates/qjs-runtime/src/tests/strings/constructor.rs +++ b/crates/qjs-runtime/src/tests/strings/constructor.rs @@ -117,3 +117,27 @@ fn new_string_with_symbol_throws() { ); assert!(eval("new String(Symbol('x'));").is_err()); } + +#[test] +fn new_string_of_a_string_or_number_builds_an_ordinary_wrapper() { + assert_eq!( + eval( + "function wrap() { + var out = []; + for (var i = 0; i < 3; i++) { + var s = new String('ab'), n = new String(12.5), z = new String(-0); + out.push(s.length, s[1], typeof s, Object.getPrototypeOf(s) === String.prototype, + Object.getOwnPropertyNames(s).join('|'), n + '!', n.length, z + '', + Object.getOwnPropertyDescriptor(s, 'length').writable); + } + class S extends String {} + out.push(new S('q') instanceof S, new (String.bind(null, 'b'))() + ''); + return out.join(); + } + wrap();" + ), + Ok(Value::String( + ("2,b,object,true,0|1|length,12.5!,4,0,false,".repeat(3) + "true,b").into() + )) + ); +} diff --git a/crates/qjs-runtime/src/tests/strings/objects.rs b/crates/qjs-runtime/src/tests/strings/objects.rs index 932c2fbb7..0058e226b 100644 --- a/crates/qjs-runtime/src/tests/strings/objects.rs +++ b/crates/qjs-runtime/src/tests/strings/objects.rs @@ -229,3 +229,32 @@ fn string_wrapper_to_primitive_follows_overridden_methods() { )) ); } + +#[test] +fn addition_honours_to_primitive_anywhere_on_the_chain() { + assert_eq!( + eval( + "function run() { + var out = [], w = new String('w'); + out.push('0' + w, w + 1, 1 + w); + var o = {}; o[Symbol.toPrimitive] = function (hint) { return 'P' + hint; }; + out.push(o + '', Object.create(o) + '!'); + String.prototype[Symbol.toPrimitive] = function () { return 'SP'; }; + out.push(w + ''); + delete String.prototype[Symbol.toPrimitive]; + Object.prototype[Symbol.toPrimitive] = function () { return 'OP'; }; + out.push(w + '', {} + ''); + delete Object.prototype[Symbol.toPrimitive]; + w.valueOf = function () { return 'own'; }; + out.push(w + '', ({ valueOf() { return 2; } }) + 1); + return out.join(); + } + run();" + ), + Ok(Value::String( + "0w,w1,1w,Pdefault,Pdefault!,SP,OP,OP,own,3" + .to_owned() + .into() + )) + ); +} diff --git a/crates/qjs-runtime/src/value/object/ordered_data_builder.rs b/crates/qjs-runtime/src/value/object/ordered_data_builder.rs index 1f9bcbbde..7f2f6c958 100644 --- a/crates/qjs-runtime/src/value/object/ordered_data_builder.rs +++ b/crates/qjs-runtime/src/value/object/ordered_data_builder.rs @@ -25,6 +25,25 @@ impl OrderedDataPropertyBuilder { } } + /// A builder whose storage has room for `capacity` properties. + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + properties: PropertyStorage::Small { + entries: Vec::with_capacity(capacity), + }, + index_property_count: 0, + } + } + + /// Defines `key` with an arbitrary descriptor, as `insert` does for an + /// enumerable data property. + pub(crate) fn insert_property(&mut self, key: Rc, property: Property) { + let classify_key = Rc::clone(&key); + if self.properties.insert(key, property).is_none() && is_array_index_key(&classify_key) { + self.index_property_count += 1; + } + } + /// Applies one CreateDataProperty-style update. The generic storage insert /// path overwrites duplicates in place and promotes only when a unique key /// exceeds the compact-storage limit. Array-index classification happens diff --git a/tasks/T033-wide-tier-interpreter-exits.md b/tasks/T033-wide-tier-interpreter-exits.md index d2f5d2e0c..fbd74266c 100644 --- a/tasks/T033-wide-tier-interpreter-exits.md +++ b/tasks/T033-wide-tier-interpreter-exits.md @@ -342,6 +342,45 @@ 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. +- perf25 units (4d19c880..130c27c6, branch agent/perf-23): found by + splitting each slow case into micro pieces and comparing each against + QuickJS-NG by the N-versus-2N instruction and cycle delta. A regex literal + builds its object directly (`new RegExp(string, string)` with `new.target` + the constructor: 9455 -> 3301 instructions per literal, NG 1723); `new + String(x)` likewise (`function::construct_intrinsic_directly`); ToPrimitive + skips the `@@toPrimitive` walk on an ordinary chain that lacks it, and the + generic `+` joins primitives in place; a Date method reads its time value + once; a closure created by direct-eval code that resolves none of the + eval scope's names by name drops that scope (`closure_needs_dynamic_scope` + -- the fork only that code writes, so nothing can appear later), which + puts xparb's format functions on the direct path; `x == null` in the wide + tier's conditional jump without the general operator (binary-trees + 0.968). Typed loops compare against string literals without unboxing. +- Stack run e34c35b4 vs main 5110c210 (30 blocks, cycles, same host with + a transient foreign `qjs` process; `target/comparison/perf23-e34c35b4-30b`): + external geomean **0.990** against main and **0.756 against + QuickJS-NG**; xparb **0.677** (1.46 -> 1.016 against NG), validate-input + 0.928, binary-trees 0.968, ai-astar 0.974. Worst: crypto-md5 1.042 at + identical instructions -- the null case added to + `operations::eval_binary_without_env` re-partitioned codegen units and + re-rolled the wide dispatch loop (176 bytes smaller). Moved into a + wide-only out-of-line helper in 130c27c6: md5 1.005 against main by + min-of-15 alternation, sentinels and canaries flat (screen, not a + formal run). `compact_fn::numeric_plan::run` pinned as well. +- Rejected (2026-09-26): typed-loop inherited reads remembered by value for + a dynamic-storage prototype (`Date.prototype`): prototype_method_call + +1.25% instructions, ai-astar +3% cycles, xparb gained the same without + it. Patch /tmp/tl-inherited.patch. Re-tried the incremental deopt overlay + (already rejected 2026-09-25): the memo already hits; the per-closure walk + is the cost. A discarded-value `x++` statement form in the compiler: the + loop-plan matchers (control, numeric, mutation, predicate scan, virtual + object) key on the six-op postfix statement shape, and the wide lowering + already folds it where a `Pop` follows. +- Measured (2026-09-26, instructions per operation against QuickJS-NG, + wide tier): method call `l.g()` 728 vs 343, own read `l.item` ~180 vs 22, + `if (l === l) n++` 309 vs 132 (the `if` join keeps the postfix copy); + calls to constant-return functions look at parity only because they are + closed-form leaves. Date getters run 2x faster than NG's. - 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 @@ -522,9 +561,12 @@ Plan and evidence: `tasks/performance-units/wide-tier-interpreter-exits.json` eval's environment (`apply_call_env`, `visible_local_entries`) and closure creation, not the overlay. -- Math.random from interpreted code costs 840 cycles per call (5.7x NG): - the call runs through the generic path because the typed loop cannot - call a stateful native. +- tofte (1.43 against NG): a formatDate call's fixed cost is 55k cycles + against 30k -- 26% is the deopt-overlay walk over the frame's 45 locals + for each of its 28 closures, 20% frame setup, 12% creating the closures. +- The wide tier's per-operation cost (2-3x QuickJS-NG: method call, own + and prototype reads) is now the common factor of hash-map, cdjs, + 3d-raytrace, binary-trees and raytrace-class-fields. - Admit bodies with a parameter prologue (default values) once their dead-zone behaviour is covered; CF traces count 179k general frames for them. diff --git a/tools/benchmark/layout_pin.py b/tools/benchmark/layout_pin.py index 43582afd5..c152589ca 100644 --- a/tools/benchmark/layout_pin.py +++ b/tools/benchmark/layout_pin.py @@ -71,6 +71,10 @@ "typed_loop7execute16boxed_truthiness", "typed_loop7execute14boxed_equality", "typed_loop7execute9get_named", + # Not the typed executor's callee: the wide tier's f64 call-tree + # executor, most of crypto-md5. Unpinned it floated with every edit, and + # md5 ran 1-5% more cycles at identical instructions (2026-09-26). + "compact_fn12numeric_plan3run", ) # After the budgeted callees: defined once per codegen unit, in a number of # copies that changes with unrelated code, so nothing pinned may follow them.