From 80293137b53af9a45e0c68c4a30c357066029fa9 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:13:08 -0700 Subject: [PATCH 01/10] Move the typed-loop builder's global-binding helpers to their own file compile.rs was at the 2000-line limit; the hoisted global reads and the sloppy fallback-global writes form one responsibility. No behavior change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/bytecode/typed_loop/compile.rs | 78 +---------------- .../bytecode/typed_loop/compile/globals.rs | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+), 77 deletions(-) create mode 100644 crates/qjs-runtime/src/bytecode/typed_loop/compile/globals.rs diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs b/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs index d556dc04d..8efd80418 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}; @@ -1022,83 +1023,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 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() + } +} From 4d19c8802b6cd2073fd4288ada2dd343d163a961 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:14:06 -0700 Subject: [PATCH 02/10] Compare against string literals in typed loops without unboxing `ch < "0" || ch > "9"` over a `charAt` result compiled to an unbox of `ch`, which deoptimized on its first iteration: string-validate-input's ZIP scan entered and deoptimized both of its loops on every call. A relational operator with a string-literal operand now takes the boxed comparison operation, which compares two strings by code unit and two numbers as numbers and deoptimizes on anything else (an object's `valueOf`). Only with a literal: `i < list.length` keeps the pass that reads the length as a scalar. The scan micro: 20.8k -> 7.3k instructions an iteration (QuickJS-NG 11.0k). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 7 ++-- .../typed_loop/branchy_nested_tests.rs | 18 ++++++++++ .../src/bytecode/typed_loop/compile.rs | 26 +++++++++++++-- .../src/bytecode/typed_loop/execute.rs | 33 +++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 9bbfc54ca..78e9764e2 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 49 +# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 48 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -55,10 +55,9 @@ __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 +__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/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 8efd80418..5f41a86a2 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/compile.rs @@ -662,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() @@ -1331,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/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 { From 614fce708da78e7aeaa31fd387edb1b616cb2238 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:28:18 -0700 Subject: [PATCH 03/10] Build RegExp objects directly from string arguments A regular-expression literal compiles to `new RegExp(pattern, flags)` with constant strings, and each evaluation went through the general construct path: a `prototype` property read, a hash-map-backed receiver, IsRegExp and ToString conversions that copied both strings, a SipHash lookup in the validation cache, and three property definitions that each allocated a key and a value. With `new.target` the constructor and string arguments none of that is observable, so the object is now built in one step with shared keys and the argument strings themselves, and the last validated pair is remembered by pointer. string-validate-input's literal went from 9455 to 3301 instructions per evaluation (QuickJS-NG 1723). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bytecode/compact_fn/wide/activation.rs | 7 +++ crates/qjs-runtime/src/function/call.rs | 8 +++ crates/qjs-runtime/src/regexp.rs | 56 +++++++++++++++++++ .../qjs-runtime/src/regexp/program_cache.rs | 19 +++++++ .../src/tests/regexp_program_cache.rs | 29 ++++++++++ .../src/value/object/ordered_data_builder.rs | 19 +++++++ 6 files changed, 138 insertions(+) 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..4b4d3079c 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs @@ -1499,6 +1499,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 == Some(crate::NativeFunction::RegExp) + && function.bound.is_none() + && let Some(result) = crate::regexp::construct_regexp_from_strings(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/function/call.rs b/crates/qjs-runtime/src/function/call.rs index a6e5886e8..192eba5ba 100644 --- a/crates/qjs-runtime/src/function/call.rs +++ b/crates/qjs-runtime/src/function/call.rs @@ -843,6 +843,14 @@ pub(crate) fn construct_function( }; return construct_function(real_target, forwarded_new_target, combined_arguments, env); } + if let Value::Function(function) = &target + && function.native == Some(NativeFunction::RegExp) + && target.same_value(&new_target) + && let Some(result) = + crate::regexp::construct_regexp_from_strings(function, &argument_values, env) + { + return result; + } ensure_constructor(&target)?; ensure_constructor(&new_target)?; 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/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/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 From b4e67cfa5088ed0ff6a198fa529a6dc6cde8d5de Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:41:41 -0700 Subject: [PATCH 04/10] Build String wrappers directly for string and number arguments `new String(x)` took the general construct path: a `prototype` read, a receiver, a native call that converted the argument to an owned string and then defined the wrapper's two properties on hash-map storage. With `new.target` the constructor and a string or number argument nothing in between is observable, so the wrapper is now built in one step, like a RegExp from string arguments; both share `construct_intrinsic_directly`, which the general and wide-tier construct paths call. date-format-xparb's `String.leftPad` wrapper went from 1003 to 555 cycles (QuickJS-NG 665). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bytecode/compact_fn/wide/activation.rs | 6 ++-- crates/qjs-runtime/src/function.rs | 2 ++ crates/qjs-runtime/src/function/call.rs | 5 ++-- .../src/function/intrinsic_construct.rs | 30 +++++++++++++++++++ crates/qjs-runtime/src/string.rs | 2 +- crates/qjs-runtime/src/string/constructor.rs | 26 ++++++++++++++++ .../src/tests/strings/constructor.rs | 24 +++++++++++++++ 7 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 crates/qjs-runtime/src/function/intrinsic_construct.rs 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 4b4d3079c..38e2ef393 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs @@ -1500,9 +1500,9 @@ fn construct_from_activation( return Ok(array); } if let Value::Function(function) = &callee - && function.native == Some(crate::NativeFunction::RegExp) - && function.bound.is_none() - && let Some(result) = crate::regexp::construct_regexp_from_strings(function, arguments, env) + && function.native.is_some() + && let Some(result) = + crate::function::construct_intrinsic_directly(function, arguments, env) { return 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 192eba5ba..706452f3c 100644 --- a/crates/qjs-runtime/src/function/call.rs +++ b/crates/qjs-runtime/src/function/call.rs @@ -844,10 +844,9 @@ pub(crate) fn construct_function( return construct_function(real_target, forwarded_new_target, combined_arguments, env); } if let Value::Function(function) = &target - && function.native == Some(NativeFunction::RegExp) + && function.native.is_some() && target.same_value(&new_target) - && let Some(result) = - crate::regexp::construct_regexp_from_strings(function, &argument_values, env) + && let Some(result) = super::construct_intrinsic_directly(function, &argument_values, env) { return result; } 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/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/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() + )) + ); +} From b1f6549b5b368fa3a74b592a2a677a9c6dfb2641 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:42:34 -0700 Subject: [PATCH 05/10] Skip the Symbol.toPrimitive walk on ordinary chains that lack it ToPrimitive read `@@toPrimitive` through the general symbol-property path on every object operand of `+`, cloning each prototype slot on the way to `undefined`. An ordinary object whose chain holds no such own property now answers that in one borrowed walk, the String-wrapper shortcut checks the receiver's own `valueOf` without cloning it, and the generic `+` joins two primitives through the same buffer-reusing concatenation the bytecode fast path uses instead of copying the left string first. `'0' + new String(5)` went from 646 to 444 cycles (QuickJS-NG 306). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-runtime/src/bytecode/mod.rs | 1 + .../src/bytecode/vm_string_append.rs | 5 +--- crates/qjs-runtime/src/conversion.rs | 26 +++++++++++++++-- crates/qjs-runtime/src/operations.rs | 4 +++ .../qjs-runtime/src/tests/strings/objects.rs | 29 +++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) 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/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/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/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() + )) + ); +} From 980a7d294296fdc6896593c542ca44512dfad6ea Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:46:06 -0700 Subject: [PATCH 06/10] Read a Date's time value once per method call Every Date method looked its receiver's time value up twice: once to check that it is a Date and once to read it. The read is the check. `getTime` in a loop went from 296 to 183 cycles per call (QuickJS-NG 121). Also re-pins the hot-function order for the String, RegExp and ToPrimitive changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-runtime/src/date/value.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 { From 488730851200c3f24886efec81d416a087594b77 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 03:56:30 -0700 Subject: [PATCH 07/10] Create eval-code closures without a scope they never read A function literal in direct eval code carried the eval's dynamic scope, which keeps every call of it off the slot-seeded direct path and the compact tiers: each call built a full interpreter frame and found even `String` by hashing the eval scope. When that code has no `eval` or `with` of its own, its scope is a fork nothing else writes and gains no name after the closure exists, so a closure that resolves none of its current names by name -- and has its own `this` and `arguments`, no `eval`, `with` or nested function -- now drops it. What such a closure reads is unchanged. date-format-xparb's generated format functions went from 7614 to 5785 cycles per call; the whole case went from 28.2k to 17.9k cycles per iteration (QuickJS-NG 17.6k). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-runtime/src/bytecode/compiler.rs | 4 ++- crates/qjs-runtime/src/bytecode/ir.rs | 26 ++++++++++++++ .../qjs-runtime/src/bytecode/vm/rare_ops.rs | 8 ++++- .../qjs-runtime/src/bytecode/vm_frame_init.rs | 35 +++++++++++++++++++ crates/qjs-runtime/src/tests/direct_eval.rs | 29 +++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) 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/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/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() + )) + ); +} From e34c35b43d8d65837ab15d69da8d9fe3784e316a Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 04:13:07 -0700 Subject: [PATCH 08/10] Compare against null without the general operator `node.left == null` with an object operand fell through the environment-free comparison, cloned both operands and built a throwaway frame to run the general loose equality, only to learn that nothing but `null`, `undefined` and the IsHTMLDDA host object equals `null`. The environment-free helper now answers `null`/`undefined` against anything and number against number, and the wide tier's conditional jump asks it before cloning. binary-trees ran 3.3% fewer cycles. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bytecode/compact_fn/wide/activation.rs | 7 +++++++ crates/qjs-runtime/src/html_dda.rs | 20 +++++++++++++++++++ crates/qjs-runtime/src/operations.rs | 13 +++++++++--- 3 files changed, 37 insertions(+), 3 deletions(-) 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 38e2ef393..ab4b755a3 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,13 @@ 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); + } let value = self.eval_binary(left.clone(), op, right.clone())?; Ok(crate::is_truthy(&value)) } 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 8c03efba5..51849268c 100644 --- a/crates/qjs-runtime/src/operations.rs +++ b/crates/qjs-runtime/src/operations.rs @@ -353,9 +353,10 @@ fn strict_eq(left: &Value, right: &Value) -> bool { /// `left op right` for the operand pairs whose evaluation runs no user code /// and needs no environment: strict equality always, and loose equality -/// between two strings, two booleans, or `null`/`undefined`. `None` sends -/// the caller down `eval_binary`. Executors that would otherwise build a -/// throwaway frame just to compare two strings use this first. +/// between two strings, two booleans, two numbers, or `null`/`undefined` +/// and anything. `None` sends the caller down `eval_binary`. Executors that +/// would otherwise build a throwaway frame just to compare two strings use +/// this first. pub(crate) fn eval_binary_without_env(left: &Value, op: BinaryOp, right: &Value) -> Option { if let ( BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge, @@ -379,6 +380,12 @@ pub(crate) fn eval_binary_without_env(left: &Value, op: BinaryOp, right: &Value) (Value::String(left), Value::String(right)) => string::js_string_eq(left, right), (Value::Boolean(left), Value::Boolean(right)) => left == right, (Value::Null | Value::Undefined, Value::Null | Value::Undefined) => true, + // `x == null`: nothing else equals `null` or `undefined` except + // the IsHTMLDDA host object, and no conversion runs. + (Value::Null | Value::Undefined, other) | (other, Value::Null | Value::Undefined) => { + crate::html_dda::is_html_dda(other) + } + (Value::Number(left), Value::Number(right)) => left == right, _ => return None, }, _ => return None, From 130c27c62e44b7c80e7e951e65e79c55f97001c7 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 05:44:13 -0700 Subject: [PATCH 09/10] Keep the null comparison out of the shared operator helper Extending `eval_binary_without_env` with the null and number cases made rustc re-partition the crate: the wide tier's dispatch loop came out 176 bytes smaller and crypto-md5 ran 2.5-4% more cycles at identical instructions (the formal run against main measured 1.042). The same comparison now lives in a small out-of-line helper the wide tier's conditional jump calls, the shared helper is as it was, and the loop is back to main's shape: md5 1.005 against main, binary-trees keeps its gain (0.976). Also pins `compact_fn::numeric_plan::run`, md5's hottest function, which floated with every edit. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 11 ++++++++-- .../bytecode/compact_fn/wide/activation.rs | 21 +++++++++++++++++++ crates/qjs-runtime/src/operations.rs | 13 +++--------- tools/benchmark/layout_pin.py | 4 ++++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 78e9764e2..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 48 +# 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_ @@ -57,6 +58,12 @@ __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtCsg55jX0GwzBC_3std6thread6th __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality __RNvMs8_NtCsg55jX0GwzBC_3std4pathNtB5_10Components25parse_next_component_back __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named +__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 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 ab4b755a3..fa4e36400 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation.rs @@ -100,6 +100,11 @@ impl WideActivation<'_> { { 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)) } @@ -1413,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. diff --git a/crates/qjs-runtime/src/operations.rs b/crates/qjs-runtime/src/operations.rs index 51849268c..8c03efba5 100644 --- a/crates/qjs-runtime/src/operations.rs +++ b/crates/qjs-runtime/src/operations.rs @@ -353,10 +353,9 @@ fn strict_eq(left: &Value, right: &Value) -> bool { /// `left op right` for the operand pairs whose evaluation runs no user code /// and needs no environment: strict equality always, and loose equality -/// between two strings, two booleans, two numbers, or `null`/`undefined` -/// and anything. `None` sends the caller down `eval_binary`. Executors that -/// would otherwise build a throwaway frame just to compare two strings use -/// this first. +/// between two strings, two booleans, or `null`/`undefined`. `None` sends +/// the caller down `eval_binary`. Executors that would otherwise build a +/// throwaway frame just to compare two strings use this first. pub(crate) fn eval_binary_without_env(left: &Value, op: BinaryOp, right: &Value) -> Option { if let ( BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge, @@ -380,12 +379,6 @@ pub(crate) fn eval_binary_without_env(left: &Value, op: BinaryOp, right: &Value) (Value::String(left), Value::String(right)) => string::js_string_eq(left, right), (Value::Boolean(left), Value::Boolean(right)) => left == right, (Value::Null | Value::Undefined, Value::Null | Value::Undefined) => true, - // `x == null`: nothing else equals `null` or `undefined` except - // the IsHTMLDDA host object, and no conversion runs. - (Value::Null | Value::Undefined, other) | (other, Value::Null | Value::Undefined) => { - crate::html_dda::is_html_dda(other) - } - (Value::Number(left), Value::Number(right)) => left == right, _ => return None, }, _ => return None, 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. From de53a3716db8c67d3e304a1c1ccd5457012f2ff8 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 05:49:12 -0700 Subject: [PATCH 10/10] Record the perf25 units, stack run and rejections in T033 Co-Authored-By: Claude Opus 5.5 (1M context) --- tasks/T033-wide-tier-interpreter-exits.md | 48 +++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) 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.