From 6f500ce6f4d4bc4bd6634668fc48e999443a8513 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 06:23:40 -0700 Subject: [PATCH 1/5] Enumerate for-in keys by remembered prototype chain A `for-in` site cached its key list per target object, so a loop over many objects -- `walk(k, v)` recursing through a parsed JSON tree -- rebuilt it every time: each layer's keys hashed into a shadowing set, Object.prototype's non-enumerable ones included. The site now also remembers the prototype chain of the last ordinary target, at its layout revisions, with the keys that chain contributes; an object inheriting exactly that chain enumerates its own enumerable keys and then those of the chain it does not shadow. The wide tier's `for-in` exits answer ordinary objects without building an environment, and a computed `array["length"]` skips key conversion. A two-key `for-in` went from 3599 to 1346 cycles (QuickJS-NG 1082); string-tagcloud 0.965. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/bytecode/compact_fn/property.rs | 8 ++ .../compact_fn/wide/activation/exits.rs | 16 +++ .../src/bytecode/compact_fn/wide/tests.rs | 23 ++++ .../src/bytecode/enumerate_keys_cache.rs | 118 +++++++++++++++++- crates/qjs-runtime/src/bytecode/vm_ops.rs | 37 +++++- 5 files changed, 197 insertions(+), 5 deletions(-) diff --git a/crates/qjs-runtime/src/bytecode/compact_fn/property.rs b/crates/qjs-runtime/src/bytecode/compact_fn/property.rs index 576bfe4d..4d293e7c 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/property.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/property.rs @@ -592,6 +592,14 @@ pub(super) fn get_prop_computed( message, }); } + // An array's `length` is its own non-configurable data property, so a + // computed read of it -- a `for-in` loop's `keys["length"]` -- needs no + // key conversion. + if let (Value::Array(elements), Value::String(key)) = (&object, &key_value) + && key.as_str() == "length" + { + return Ok(Value::Number(elements.len() as f64)); + } if let Value::Number(number) = &key_value && let Some(index) = crate::bytecode::vm_props::array_index_from_number(*number) { diff --git a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation/exits.rs b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation/exits.rs index a284e2af..b4b0df0b 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation/exits.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/wide/activation/exits.rs @@ -88,6 +88,12 @@ pub(super) fn exit_to_interpreter( Some(crate::bytecode::ir::Op::EnumerateKeys { cache }) => { if let Some(pc) = program.resume_pc(ip as usize + 1, usize::from(depth)) { let top = usize::from(program.local_registers) + usize::from(depth); + if let Some(keys) = + crate::bytecode::vm_ops::enumerate_keys_from_cache(&window[top - 1], cache) + { + execute::store(&mut window[top - 1], Value::Array(keys)); + return ExitOutcome::Continue { pc }; + } let target = std::mem::replace(&mut window[top - 1], Value::Undefined); let mut call_env = env.empty_frame(); return match crate::bytecode::vm_ops::enumerate_keys_cached( @@ -108,6 +114,16 @@ pub(super) fn exit_to_interpreter( if let Some(pc) = program.resume_pc(ip as usize + 1, usize::from(depth) - 1) && let Value::String(key) = &window[top - 1] { + if let Some(enumerable) = + crate::bytecode::vm_ops::for_in_ordinary_property_is_enumerable( + &window[top - 2], + key, + ) + { + execute::store(&mut window[top - 1], Value::Undefined); + execute::store(&mut window[top - 2], Value::Boolean(enumerable)); + return ExitOutcome::Continue { pc }; + } let key = key.clone(); let target = std::mem::replace(&mut window[top - 2], Value::Undefined); execute::store(&mut window[top - 1], Value::Undefined); diff --git a/crates/qjs-runtime/src/bytecode/compact_fn/wide/tests.rs b/crates/qjs-runtime/src/bytecode/compact_fn/wide/tests.rs index 48317fde..0db8645f 100644 --- a/crates/qjs-runtime/src/bytecode/compact_fn/wide/tests.rs +++ b/crates/qjs-runtime/src/bytecode/compact_fn/wide/tests.rs @@ -1488,6 +1488,29 @@ fn a_for_in_loop_stays_on_the_tier_with_the_interpreter_s_semantics() { ); } +/// A `for-in` site that enumerated one object whose prototypes had no +/// enumerable key reads only the own keys of the next object with those +/// prototypes -- until a prototype gains an enumerable key. +#[test] +fn a_for_in_site_sees_prototype_keys_appear_and_disappear() { + assert_eq!( + value_of( + "function keys(o) { var r = []; for (var k in o) { if (o[k] !== 0) r.push(k); } return r.join(','); } \ + function P() {} var out = []; \ + for (var i = 0; i < 3; i++) out.push(keys({ a: 1, b: 2 }), keys(new P())); \ + Object.prototype.late = 9; out.push(keys({ a: 1 })); delete Object.prototype.late; \ + out.push(keys({ a: 1 })); \ + P.prototype.shared = 5; out.push(keys(new P())); \ + Object.defineProperty(P.prototype, 'shared', { enumerable: false }); out.push(keys(new P())); \ + var q = { x: 1 }; Object.setPrototypeOf(q, { y: 2 }); out.push(keys(q)); \ + var shadow = Object.create({ a: 1 }); Object.defineProperty(shadow, 'a', { value: 2, enumerable: false }); \ + out.push(keys(shadow), keys({ b: 0, c: 3 })); \ + out.join('|');" + ), + Value::String("a,b||a,b||a,b||a,late|a|shared||x,y||c".to_owned().into()) + ); +} + #[test] fn methods_with_a_home_object_run_inline_and_super_or_private_bodies_keep_their_path() { // Class and object-literal methods carry a home object, which only diff --git a/crates/qjs-runtime/src/bytecode/enumerate_keys_cache.rs b/crates/qjs-runtime/src/bytecode/enumerate_keys_cache.rs index 7e658526..4af9fe99 100644 --- a/crates/qjs-runtime/src/bytecode/enumerate_keys_cache.rs +++ b/crates/qjs-runtime/src/bytecode/enumerate_keys_cache.rs @@ -14,7 +14,28 @@ use crate::{ArrayRef, ObjectRef, Prototype, Value, value::ObjectWeakRef}; const MAX_ORDINARY_CHAIN_DEPTH: usize = 16; #[derive(Clone, Default)] -pub(super) struct EnumerateKeysCache(Rc>>); +pub(super) struct EnumerateKeysCache(Rc>); + +#[derive(Default)] +struct EnumerateKeysCacheState { + entry: Option, + /// The prototype chain -- the links after a target, to the end -- of the + /// last ordinary target, at these layout revisions, with the keys that + /// chain contributes to a `for-in` (its own enumeration, shadowing among + /// its layers already applied). A target inheriting exactly this chain + /// enumerates its own enumerable keys, then those of these it does not + /// have as own properties. `walk(k, v)` recursing over many objects of + /// one shape misses the per-target entry every time. + /// Boxed so the cache stays one small allocation: growing it moved + /// every later heap allocation of a script, and recursive_call_tree's + /// helper program with them (+10% cycles, 2026-09-26). + prototypes: Option>, +} + +struct PrototypeKeys { + chain: Vec, + inherited: Vec>, +} impl fmt::Debug for EnumerateKeysCache { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -37,18 +58,107 @@ impl EnumerateKeysCache { /// depended on still has the same identity, own-key layout, and ordinary /// object prototype link. pub(super) fn get(&self, target: &Value) -> Option { - let entry = self.0.borrow(); - let entry = entry.as_ref()?; + let state = self.0.borrow(); + let entry = state.entry.as_ref()?; ordinary_chain_matches(target, &entry.chain).then(|| entry.keys.clone()) } + /// The keys of an ordinary `target` whose prototype chain is the + /// remembered one: its own enumerable string keys in order, then the + /// chain's keys that it does not shadow with an own property. + pub(super) fn get_by_prototypes(&self, target: &Value) -> Option { + let Value::Object(object) = target else { + return None; + }; + if !is_cacheable_ordinary_object(object) { + return None; + } + let state = self.0.borrow(); + let prototypes = state.prototypes.as_ref()?; + match object.prototype_slot() { + Some(Prototype::Object(prototype)) => { + if !ordinary_chain_matches(&Value::Object(prototype), &prototypes.chain) { + return None; + } + } + None if prototypes.chain.is_empty() => {} + _ => return None, + } + let own = object.own_string_keys_with_enumerability(); + let key_value = |key: &Rc| Value::String(crate::JsString::from(&**key)); + let mut keys: Vec = own + .iter() + .filter(|(_, enumerable)| *enumerable) + .map(|(key, _)| key_value(key)) + .collect(); + for key in &prototypes.inherited { + if !own.iter().any(|(own_key, _)| own_key == key) { + keys.push(key_value(key)); + } + } + Some(ArrayRef::new(keys)) + } + /// Records a just-enumerated key array when `target` has an entirely /// ordinary object chain. Unsupported values intentionally clear a stale /// entry: a future ordinary target must rebuild before it can be cached. + /// Also remembers the chain after `target` and the keys it contributes + /// (see `get_by_prototypes`), unless that chain is already remembered. pub(super) fn record(&self, target: &Value, keys: ArrayRef) { let chain = capture_ordinary_chain(target); - *self.0.borrow_mut() = chain.map(|chain| EnumerateKeysCacheEntry { chain, keys }); + let mut state = self.0.borrow_mut(); + if let Some(chain) = &chain { + let prototype_chain = &chain[1..]; + let current = state.prototypes.as_ref().is_some_and(|known| { + known.chain.len() == prototype_chain.len() + && known + .chain + .iter() + .zip(prototype_chain) + .all(|(known, link)| { + known.layout_revision == link.layout_revision + && link + .object + .upgrade() + .is_some_and(|object| known.object.ptr_eq(&object)) + }) + }); + if !current { + state.prototypes = prototype_keys(prototype_chain).map(Box::new); + } + } + state.entry = chain.map(|chain| EnumerateKeysCacheEntry { chain, keys }); + } +} + +/// The keys a `for-in` over an object with `chain` as its prototypes visits +/// from that chain: each layer's enumerable own string keys, less any name an +/// earlier layer has (enumerable or not). +fn prototype_keys(chain: &[OrdinaryChainLink]) -> Option { + let mut seen: Vec> = Vec::new(); + let mut inherited = Vec::new(); + for link in chain { + let object = link.object.upgrade()?; + for (key, enumerable) in object.own_string_keys_with_enumerability() { + if seen.contains(&key) { + continue; + } + if enumerable { + inherited.push(key.clone()); + } + seen.push(key); + } } + Some(PrototypeKeys { + chain: chain + .iter() + .map(|link| OrdinaryChainLink { + object: link.object.clone(), + layout_revision: link.layout_revision, + }) + .collect(), + inherited, + }) } fn capture_ordinary_chain(target: &Value) -> Option> { diff --git a/crates/qjs-runtime/src/bytecode/vm_ops.rs b/crates/qjs-runtime/src/bytecode/vm_ops.rs index 46017af7..b45ace68 100644 --- a/crates/qjs-runtime/src/bytecode/vm_ops.rs +++ b/crates/qjs-runtime/src/bytecode/vm_ops.rs @@ -191,7 +191,7 @@ pub(in crate::bytecode) fn enumerate_keys_cached( cache: &EnumerateKeysCache, env: &mut CallEnv, ) -> Result { - if let Some(keys) = cache.get(value) { + if let Some(keys) = enumerate_keys_from_cache(value, cache) { return Ok(keys); } let keys = ArrayRef::new(enumerable_keys(value.clone(), env)?); @@ -199,6 +199,41 @@ pub(in crate::bytecode) fn enumerate_keys_cached( Ok(keys) } +/// `enumerate_keys_cached` where the site's cache answers, which needs no +/// environment. +pub(in crate::bytecode) fn enumerate_keys_from_cache( + value: &Value, + cache: &EnumerateKeysCache, +) -> Option { + cache.get(value).or_else(|| cache.get_by_prototypes(value)) +} + +/// `for_in_property_is_enumerable` for an object whose chain, up to the +/// holder of `key` or its end, is ordinary: answered from storage without an +/// environment. `None` at the first exotic layer. +pub(in crate::bytecode) fn for_in_ordinary_property_is_enumerable( + target: &Value, + key: &str, +) -> Option { + let Value::Object(object) = target else { + return None; + }; + let mut current = object.clone(); + loop { + if current.is_module_namespace_exotic() || current.is_typed_array_exotic() { + return None; + } + if let Some(enumerable) = current.own_property_enumerable(key) { + return Some(enumerable); + } + match current.prototype_slot() { + None => return Some(false), + Some(crate::value::Prototype::Object(prototype)) => current = prototype, + Some(_) => return None, + } + } +} + /// Walks `target`'s live `[[Prototype]]` chain looking for an own descriptor /// of `key`, dispatching each Proxy's `[[GetOwnProperty]]` and /// `[[GetPrototypeOf]]` traps. A structural descriptor lookup is not From 247c86b77e6d7d348560a5ce732f36d8cf1b41d7 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 06:24:25 -0700 Subject: [PATCH 2/5] Align the numeric helper's register file and pin its executor The typed loop's f64 helper executor fills its register file on every call, and where the caller's frames left the stack decided how many instructions the fill took: recursive_call_tree executed 0.9% more instructions after an unrelated change resized a frame above it. The file is now its own 64-byte-aligned type, which makes the count independent of the callers (0.998 against main). `NumProgram::run` is pinned in the hot-function order next to the wide tier's numeric executor. The sentinel still runs about 10% more cycles than on main with byte-identical helper code at the same offset: its read-only jump tables moved with the constant data of unrelated code, which the order file cannot place. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 13 +++++++++---- .../src/bytecode/typed_loop/helper_graph/numeric.rs | 11 ++++++++++- tools/benchmark/layout_pin.py | 4 ++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 4fbd9c76..4929b07a 100644 --- a/crates/qjs-cli/hot-functions.order +++ b/crates/qjs-cli/hot-functions.order @@ -13,8 +13,9 @@ # budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness # budget 0x300 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality # 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 +# budget 0x1ee0 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn12numeric_plan3run +# budget 0x1700 __RNvMNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop12helper_graph7numericNtB2_10NumProgram3run +# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 59 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -64,8 +65,13 @@ __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_ +__RNvNtNtNtNtNtCsg55jX0GwzBC_3std3sys3pal4unix14stack_overflow3imp12drop_handler +__RNvNvNtCsg55jX0GwzBC_3std2fs4read5inner +__RNvMs_NtNtNtNtCsg55jX0GwzBC_3std12backtrace_rs9symbolize5gimli5machoNtB4_6Object7section +__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtNtCsg55jX0GwzBC_3std3sys2fs4unix12InnerReadDirE9drop_slowBO_ __RNvMs4_NtCs1OjIl8oxbrv_5alloc7raw_vecINtB5_11RawVecInnerNtNtCsg55jX0GwzBC_3std5alloc6SystemE14grow_amortizedBW_ +__RNvMNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop12helper_graph7numericNtB2_10NumProgram3run +__RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtNtNtB9_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RNvXs1i_NtCsl8K0bEFm1U0_4core3fmtRNtNtNtB8_5panic8location8LocationNtB6_7Display3fmtCsg55jX0GwzBC_3std __RNvXs1_NtCs9nYd1Hk1rek_11qjs_runtime5valueNtB5_5ValueNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone __RINvNtCsl8K0bEFm1U0_4core3ptr13drop_in_placeNtNtCs9nYd1Hk1rek_11qjs_runtime5value5ValueEBK_ @@ -221,7 +227,6 @@ __RNvMNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode2vm11general_opsNtB4_2Vm11op_set __RNvMs_NtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode8vm_propsNtNtB6_2vm2Vm21try_direct_get_string __RINvNtCsl8K0bEFm1U0_4core3ptr13drop_in_placeTINtNtCs1OjIl8oxbrv_5alloc2rc2RceENtNtNtCs9nYd1Hk1rek_11qjs_runtime5value8property8PropertyEEB1k_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn8property16get_prop_element -__RNvMNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop12helper_graph7numericNtB2_10NumProgram3run __RNvMs1_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_8ArrayRef12release_into __RNvXs4_NtCs1OjIl8oxbrv_5alloc6stringNtB5_6StringNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone __RINvNvMs2_NtCs1OjIl8oxbrv_5alloc7raw_vecINtB8_11RawVecInnerpE7reserve21do_reserve_and_handleNtNtBa_5alloc6GlobalECs9nYd1Hk1rek_11qjs_runtime diff --git a/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs b/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs index ddc737cd..34c3576c 100644 --- a/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs +++ b/crates/qjs-runtime/src/bytecode/typed_loop/helper_graph/numeric.rs @@ -230,6 +230,14 @@ pub(super) struct NumProgram { constants: Box<[(u16, f64)]>, } +/// A call's registers, on a cache line of their own: filled on every call, +/// so where the caller's frames left the stack decided how many lines the +/// fill and each access touched -- recursive_call_tree swung 10% in cycles +/// with identical helper code when an unrelated change resized a frame +/// above it (2026-09-26). +#[repr(align(64))] +struct RegisterFile([f64; FILE]); + impl NumProgram { /// Lowers `ops`, whose first `arity` registers are the arguments, or /// `None` when the encoding could be observed. @@ -390,7 +398,8 @@ impl NumProgram { // Every register that is not an argument starts `undefined`. The file // is a power of two wider than any register the helper names, so an // operand is masked into range rather than bounds-checked. - let mut r = [f64::NAN; FILE]; + let mut file = RegisterFile([f64::NAN; FILE]); + let r = &mut file.0; for (register, argument) in r.iter_mut().zip(args) { *register = *argument; } diff --git a/tools/benchmark/layout_pin.py b/tools/benchmark/layout_pin.py index c152589c..f0902cdf 100644 --- a/tools/benchmark/layout_pin.py +++ b/tools/benchmark/layout_pin.py @@ -75,6 +75,10 @@ # 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", + # The typed loop's f64 helper-graph executor: nearly all of + # recursive_call_tree, which ran 10% more cycles when an unrelated + # change moved it (2026-09-26). + "helper_graph7numericNtB2_10NumProgram3run", ) # 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 fe533cc79b0d3783f3d830697264d4bf38970aa1 Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 06:35:16 -0700 Subject: [PATCH 3/5] Let closures bypass a function's eval scope until it changes A function with a direct `eval` gives every closure it creates its dynamic scope, so each call of one built a full interpreter frame even when the closure resolves none of the scope's names -- string-tagcloud's JSON `walk` and date-format-tofte's formatters. Such a closure now keeps the scope but bypasses it: the direct call path and the compact tiers take it as scope free, and the scope records it and revokes the bypass, with every fact derived from it, the moment it adds, removes or remaps a name -- a later `eval('var String = 5')` in that function is then visible to the closure. Frames that can suspend keep the scope, and a scope lets at most 64 closures bypass it at a time. The rule for closures made by direct-eval code itself (which drop the scope outright) is now the same decision. string-tagcloud 0.784, date-format-tofte 0.979. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/qjs-cli/hot-functions.order | 35 +++++----- crates/qjs-runtime/src/bytecode/ir.rs | 12 ++++ .../qjs-runtime/src/bytecode/vm/rare_ops.rs | 20 ++++-- .../qjs-runtime/src/bytecode/vm_frame_init.rs | 67 +++++++++++++------ crates/qjs-runtime/src/function/call.rs | 6 +- crates/qjs-runtime/src/function/env.rs | 40 +++++++++++ crates/qjs-runtime/src/function/value.rs | 25 +++++++ crates/qjs-runtime/src/tests/direct_eval.rs | 35 ++++++++++ 8 files changed, 194 insertions(+), 46 deletions(-) diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 4929b07a..e7b00de4 100644 --- a/crates/qjs-cli/hot-functions.order +++ b/crates/qjs-cli/hot-functions.order @@ -15,7 +15,7 @@ # budget 0xe60 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named # budget 0x1ee0 __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn12numeric_plan3run # budget 0x1700 __RNvMNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop12helper_graph7numericNtB2_10NumProgram3run -# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 59 +# pinned: typed-loop executor at 0x200, interpreter's at 0xc40 mod 4 KiB (python3 -m tools.benchmark.layout_pin), head 56 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -29,8 +29,7 @@ __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute18try_run_ty __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterINtNtCs1OjIl8oxbrv_5alloc3vec3VechEENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ __RNvYNtNtNtNtCsg55jX0GwzBC_3std3sys5stdio4unix6StderrNtNtBa_2io5Write9write_allBa_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16get_named_object -__RNvXso_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StderrNtB7_5Write9write_fmt -__RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt +__RNvNvNtCsg55jX0GwzBC_3std2fs4read5inner __RNvMs4_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_8ArrayRef24direct_dense_index_value __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtCscUtGwbhD4WH_5gimli4read5dwarf5DwarfINtNtBL_12endian_slice11EndianSliceNtNtBN_9endianity12LittleEndianEEE9drop_slowCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5stdioNtB5_9StdinLockNtB7_7BufRead9read_line @@ -39,23 +38,21 @@ __RNvYINtNtNtCscUtGwbhD4WH_5gimli4read12endian_slice11EndianSliceNtNtB9_9endiani __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf21try_eval_numeric_leaf __RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterNtNtNtNtB6_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvMs3_NtNtCs9nYd1Hk1rek_11qjs_runtime5value5arrayNtB5_9ArrayData21has_property_at_index -__RNvXs_NvNtCsg55jX0GwzBC_3std9panicking13panic_handlerNtB4_19FormatStringPayloadNtNtCsl8K0bEFm1U0_4core5panic12PanicPayload3get -__RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterINtNtB4_6cursor6CursorQShEENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ -__RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE14write_all_coldBa_ +__RNvXso_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StderrNtB7_5Write9write_fmt +__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode15vm_numeric_leaf20direct_number_binary -__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtNtCsg55jX0GwzBC_3std4sync6poison5mutex5MutexINtNtB7_3vec3VechEEE9drop_slowBP_ -__RNvXs1g_NtCsl8K0bEFm1U0_4core3fmtRINtNtCs1OjIl8oxbrv_5alloc3vec3VechENtB6_5Debug3fmtCsg55jX0GwzBC_3std +__RNvXse_NtNtCsg55jX0GwzBC_3std2io5stdioRNtB5_6StdoutNtB7_5Write9write_fmt __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute22ordinary_data_property -__RNvNtNtCsg55jX0GwzBC_3std2io5stdio7__eprint -__RNvNtNtCsg55jX0GwzBC_3std3sys9backtrace4lock +__RNvXs_NvNtCsg55jX0GwzBC_3std9panicking13panic_handlerNtB4_19FormatStringPayloadNtNtCsl8K0bEFm1U0_4core5panic12PanicPayload3get +__RNvMs_NtNtNtCsg55jX0GwzBC_3std2io8buffered9bufwriterINtB4_9BufWriterNtNtB8_5stdio9StdoutRawE14write_all_coldBa_ __RNvMNtNtNtCs9nYd1Hk1rek_11qjs_runtime5value6object10slot_readsNtB4_9ObjectRef22own_data_property_read __RNvMs1_NtNtNtNtCsg55jX0GwzBC_3std3sys4sync6rwlock5queueNtB5_6RwLock21read_unlock_contended __RNvMs1_NtNtNtNtCsg55jX0GwzBC_3std3sys4sync6rwlock5queueNtB5_6RwLock16unlock_contended __RNvMs6_NtNtCs9nYd1Hk1rek_11qjs_runtime5value6objectNtB5_9ObjectRef32write_existing_own_data_property __RNvNtNtNtCsg55jX0GwzBC_3std12backtrace_rs9symbolize5gimli4mmap __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute16boxed_truthiness -__RNvNtNtCsg55jX0GwzBC_3std3sys2fs8read_dir -__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtCsg55jX0GwzBC_3std6thread6thread5InnerNtNtBM_5alloc6SystemE9drop_slowBM_ +__RNvNtNtNtNtCsg55jX0GwzBC_3std3sys2io5error4unix17decode_error_kind +__RNvXNvNtCsg55jX0GwzBC_3std2io17default_write_fmtINtB2_7AdapterINtNtB4_6cursor6CursorQShEENtNtCsl8K0bEFm1U0_4core3fmt5Write9write_strB6_ __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute14boxed_equality __RNvMs8_NtCsg55jX0GwzBC_3std4pathNtB5_10Components25parse_next_component_back __RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute9get_named @@ -63,16 +60,16 @@ __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 -__RNvNtNtNtNtNtCsg55jX0GwzBC_3std3sys3pal4unix14stack_overflow3imp12drop_handler -__RNvNvNtCsg55jX0GwzBC_3std2fs4read5inner __RNvMs_NtNtNtNtCsg55jX0GwzBC_3std12backtrace_rs9symbolize5gimli5machoNtB4_6Object7section +__RNvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10compact_fn12numeric_plan3run +__RNvMNtCs2IzQ63mrjeP_9addr2line4unitINtB2_7ResUnitINtNtNtCscUtGwbhD4WH_5gimli4read12endian_slice11EndianSliceNtNtBS_9endianity12LittleEndianEE25find_function_or_locationCsg55jX0GwzBC_3std +__RNvNtCsg55jX0GwzBC_3std5panic19get_backtrace_style __RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcNtNtNtNtCsg55jX0GwzBC_3std3sys2fs4unix12InnerReadDirE9drop_slowBO_ -__RNvMs4_NtCs1OjIl8oxbrv_5alloc7raw_vecINtB5_11RawVecInnerNtNtCsg55jX0GwzBC_3std5alloc6SystemE14grow_amortizedBW_ -__RNvMNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop12helper_graph7numericNtB2_10NumProgram3run __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtNtNtB9_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ -__RNvXs1i_NtCsl8K0bEFm1U0_4core3fmtRNtNtNtB8_5panic8location8LocationNtB6_7Display3fmtCsg55jX0GwzBC_3std +__RNvNtNtCsg55jX0GwzBC_3std3sys2fs12canonicalize +__RNvMNtNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop12helper_graph7numericNtB2_10NumProgram3run +__RNvMsn_NtCs1OjIl8oxbrv_5alloc4syncINtB5_3ArcINtNtNtNtCsg55jX0GwzBC_3std4sync6poison5mutex5MutexINtNtB7_3vec3VechEEE9drop_slowBP_ +__RNvXs1g_NtCsl8K0bEFm1U0_4core3fmtRINtNtCs1OjIl8oxbrv_5alloc3vec3VechENtB6_5Debug3fmtCsg55jX0GwzBC_3std __RNvXs1_NtCs9nYd1Hk1rek_11qjs_runtime5valueNtB5_5ValueNtNtCsl8K0bEFm1U0_4core5clone5Clone5clone __RINvNtCsl8K0bEFm1U0_4core3ptr13drop_in_placeNtNtCs9nYd1Hk1rek_11qjs_runtime5value5ValueEBK_ __RNvNtNtCs9nYd1Hk1rek_11qjs_runtime8function4call25call_direct_leaf_function diff --git a/crates/qjs-runtime/src/bytecode/ir.rs b/crates/qjs-runtime/src/bytecode/ir.rs index 9bed56af..dc20fdbe 100644 --- a/crates/qjs-runtime/src/bytecode/ir.rs +++ b/crates/qjs-runtime/src/bytecode/ir.rs @@ -774,6 +774,8 @@ pub struct Bytecode { cached_authoritative_mask_clean: u128, /// The top level of a direct eval's code (not a function inside it). direct_eval_code: bool, + /// Whether the body can suspend mid-way: a `yield`, `yield*` or `await`. + cached_suspends: bool, } impl Bytecode { @@ -943,6 +945,7 @@ impl Bytecode { cached_hoisted_slots: Vec::new(), cached_authoritative_mask_clean: 0, direct_eval_code: false, + cached_suspends: false, }; // Order matters: closure/arguments metadata reads the simpler caches // (written-binding names, creates-closures) computed just above. Nested @@ -990,6 +993,10 @@ impl Bytecode { ) }); bytecode.cached_uses_lexical_this = bytecode.compute_uses_lexical_this(); + bytecode.cached_suspends = bytecode + .code + .iter() + .any(|op| matches!(op, Op::Yield | Op::Await | Op::YieldDelegate { .. })); bytecode.readonly_received_upvalue_slots = bytecode.compute_readonly_received_upvalue_slots(); bytecode.cell_received_upvalue_slots = bytecode.compute_cell_received_upvalue_slots(); @@ -1428,6 +1435,11 @@ impl Bytecode { self.direct_eval_code = true; } + /// Whether the body can suspend mid-way (`yield`, `yield*`, `await`). + pub(super) fn suspends(&self) -> bool { + self.cached_suspends + } + /// 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 diff --git a/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs b/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs index 1ad9889f..57873fcf 100644 --- a/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs +++ b/crates/qjs-runtime/src/bytecode/vm/rare_ops.rs @@ -193,11 +193,18 @@ impl Vm<'_> { } else { None }; - let deopt_bindings = self.frame_deopt_bindings_memoized().filter(|bindings| { - self.closure_needs_dynamic_scope( - bytecode, - *lexical_this || *lexical_arguments, - bindings, + let deopt_bindings = self.frame_deopt_bindings_memoized(); + let scope_use = deopt_bindings.as_ref().map(|bindings| { + self.closure_scope_use(bytecode, *lexical_this || *lexical_arguments, bindings) + }); + let deopt_bindings = match scope_use { + Some(crate::bytecode::vm_frame_init::ClosureScopeUse::Drop) => None, + _ => deopt_bindings, + }; + let bypass_scope = deopt_bindings.clone().filter(|_| { + matches!( + scope_use, + Some(crate::bytecode::vm_frame_init::ClosureScopeUse::Bypass) ) }); let function = Function::new_user_compiled(CompiledUserFunction { @@ -233,6 +240,9 @@ impl Vm<'_> { with_stack: self.with_stack().to_vec(), upvalues, }); + if let Some(bindings) = bypass_scope { + bindings.bypass(&function); + } self.capture_private_environment(&function); if *is_generator && *is_async { crate::async_generator::wire_async_generator_function_intrinsics( diff --git a/crates/qjs-runtime/src/bytecode/vm_frame_init.rs b/crates/qjs-runtime/src/bytecode/vm_frame_init.rs index 7a03625c..eadc6ab2 100644 --- a/crates/qjs-runtime/src/bytecode/vm_frame_init.rs +++ b/crates/qjs-runtime/src/bytecode/vm_frame_init.rs @@ -386,39 +386,58 @@ 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. + /// How a function literal this frame evaluates may treat the frame's + /// dynamic scope `bindings`. Without it, the closure is 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( + /// The closure must not reach the scope another way -- no `eval` or + /// `with`, no nested function to hand it on, its own `this` and + /// `arguments` -- and nothing it resolves by name may be in the scope + /// now. Then: + /// + /// - In a direct eval's top-level code with no `eval` or `with` of its + /// own, the scope is a fork only that code writes, and its `var`s are + /// in it from the start: no name can appear later, so the closure drops + /// it (`Drop`). `format0 = function () { return this.getFullYear() + + /// ... }` in date-format-xparb is the case. + /// - In a function body that has a direct `eval`, a later `eval` there + /// can add a `var` the closure would then have to see, so the closure + /// keeps the scope but bypasses it until the scope next changes which + /// names it binds (`Bypass`; `DynamicBindings::bypass`). A frame that + /// can suspend is left alone: its `eval` could run while one of these + /// closures is mid-call. date-format-tofte's formatters and + /// string-tagcloud's `walk` are the case. + pub(super) fn closure_scope_use( &self, closure: &Bytecode, inherits_frame: bool, bindings: &DynamicBindings, - ) -> bool { + ) -> ClosureScopeUse { let frame: &Bytecode = &self.bytecode; - !frame.is_direct_eval_code() - || frame.contains_direct_eval() - || frame.contains_with() + if inherits_frame || !self.with_stack().is_empty() - || inherits_frame + || frame.contains_with() || closure.contains_direct_eval() || closure.contains_with() || closure.creates_closures() || closure .names_resolved_by_name() .any(|name| name != "this" && bindings.contains_key(name)) + { + return ClosureScopeUse::Keep; + } + if frame.is_direct_eval_code() { + return if frame.contains_direct_eval() { + ClosureScopeUse::Keep + } else { + ClosureScopeUse::Drop + }; + } + if frame.is_global_scope() || frame.suspends() { + return ClosureScopeUse::Keep; + } + ClosureScopeUse::Bypass } /// `frame_deopt_bindings` for a frame that asks repeatedly -- every @@ -460,6 +479,14 @@ impl<'a> Vm<'a> { } } +/// What a new closure does with its creating frame's dynamic scope +/// (`Vm::closure_scope_use`). +pub(super) enum ClosureScopeUse { + Keep, + Drop, + Bypass, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/qjs-runtime/src/function/call.rs b/crates/qjs-runtime/src/function/call.rs index 706452f3..3fb6b45f 100644 --- a/crates/qjs-runtime/src/function/call.rs +++ b/crates/qjs-runtime/src/function/call.rs @@ -1266,7 +1266,9 @@ fn function_env<'a>( frame_env.set_module_imports(function.module_imports.clone()); } frame_env.set_private_environment(function_private_environment(function)); - if let Some(bindings) = &function.deopt_bindings { + if let Some(bindings) = &function.deopt_bindings + && !function.scope_bypassed.get() + { frame_env.set_deopt_bindings(bindings.clone()); } let direct_call_slots = use_direct_call_slots.then(|| DirectCallSlots { @@ -1349,7 +1351,7 @@ fn can_seed_slot_backed_call(function: &Function, bytecode: &Bytecode) -> bool { && (function.immutable_env_binding.as_deref().is_none_or(|name| { function.is_field_initializer || bytecode.reads_immutable_env_binding_through_cell(name) })) - && function.deopt_bindings.is_none() + && function.dynamic_scope_bypassed() && function.with_stack.is_empty() && direct_seedable_parameter_list(&function.params) && !bytecode.needs_arguments_object() diff --git a/crates/qjs-runtime/src/function/env.rs b/crates/qjs-runtime/src/function/env.rs index 3308325b..c9cb8af7 100644 --- a/crates/qjs-runtime/src/function/env.rs +++ b/crates/qjs-runtime/src/function/env.rs @@ -499,9 +499,44 @@ pub(crate) struct DynamicBindingsInner { /// a replaced or removed cell -- but not by writes through a cell. Equal /// generations mean every name still maps to the cell it did. generation: std::cell::Cell, + /// Closures created bypassing this scope (`bypass`), revoked on the next + /// generation change. + bypasses: RefCell>, } +/// Closures one scope lets bypass it at a time; past this, closures keep the +/// scope, so a frame creating closures in a loop does not grow the list. +const MAX_SCOPE_BYPASSES: usize = 64; + impl DynamicBindings { + /// Lets `function` run without this scope until a name is added, + /// removed or remapped here. The caller has proved that nothing the + /// function resolves by name is in the scope now. `false` when the + /// scope already has its fill of bypassing closures. + pub(crate) fn bypass(&self, function: &super::Function) -> bool { + let mut bypasses = self.0.bypasses.borrow_mut(); + if bypasses.len() >= MAX_SCOPE_BYPASSES { + bypasses.retain(|weak| weak.upgrade().is_some()); + if bypasses.len() >= MAX_SCOPE_BYPASSES { + return false; + } + } + bypasses.push(function.downgrade()); + function.scope_bypassed.set(true); + true + } + + #[cold] + #[inline(never)] + fn revoke_bypasses(&self) { + let bypasses = std::mem::take(&mut *self.0.bypasses.borrow_mut()); + for function in bypasses { + if let Some(function) = function.upgrade() { + function.revoke_scope_bypass(); + } + } + } + pub(crate) fn new() -> Self { Self::default() } @@ -513,6 +548,9 @@ impl DynamicBindings { self.0 .generation .set(self.0.generation.get().wrapping_add(1)); + if !self.0.bypasses.borrow().is_empty() { + self.revoke_bypasses(); + } self.0.map.borrow_mut() } @@ -540,6 +578,7 @@ impl DynamicBindings { .collect(), ), generation: std::cell::Cell::new(0), + bypasses: RefCell::default(), })) } @@ -547,6 +586,7 @@ impl DynamicBindings { Self(Rc::new(DynamicBindingsInner { map: RefCell::new(self.0.map.borrow().clone()), generation: std::cell::Cell::new(0), + bypasses: RefCell::default(), })) } diff --git a/crates/qjs-runtime/src/function/value.rs b/crates/qjs-runtime/src/function/value.rs index 75d37062..51a6acdd 100644 --- a/crates/qjs-runtime/src/function/value.rs +++ b/crates/qjs-runtime/src/function/value.rs @@ -197,6 +197,11 @@ pub struct FunctionData { /// it depends on the function alone, holds, and the register count. pub(crate) compact_inline_facts: Cell, pub(crate) deopt_bindings: Option, + /// Whether calls may ignore `deopt_bindings`: set when the closure was + /// created resolving none of that scope's names, and revoked by the scope + /// itself as soon as a name is added, removed or remapped there + /// (`DynamicBindings::bypass`). + pub(crate) scope_bypassed: Cell, pub(crate) module_host: Option, pub(crate) module_imports: ModuleImports, pub(crate) with_stack: Vec, @@ -609,6 +614,7 @@ impl Function { native_family: Cell::new(0), wide_inline_facts: Cell::new(0), compact_inline_facts: Cell::new(0), + scope_bypassed: Cell::new(false), deopt_bindings: None, module_host: None, module_imports: Default::default(), @@ -709,6 +715,7 @@ impl Function { native_family: Cell::new(0), wide_inline_facts: Cell::new(0), compact_inline_facts: Cell::new(0), + scope_bypassed: Cell::new(false), deopt_bindings, module_host, module_imports, @@ -788,6 +795,22 @@ impl Function { FunctionWeakRef(Rc::downgrade(&self.0)) } + /// Whether this function's calls may run without its dynamic scope. + pub(crate) fn dynamic_scope_bypassed(&self) -> bool { + self.deopt_bindings.is_none() || self.scope_bypassed.get() + } + + /// Ends a bypass of the dynamic scope, forgetting every call-path fact + /// derived while it held. + pub(crate) fn revoke_scope_bypass(&self) { + if self.scope_bypassed.replace(false) { + self.direct_leaf_call_eligible.set(None); + self.direct_construct_eligible.set(None); + self.wide_inline_facts.set(0); + self.compact_inline_facts.set(0); + } + } + pub(crate) fn is_uninitialized_lexical_marker(&self) -> bool { matches!(self.native, Some(NativeFunction::UninitializedLexical)) } @@ -830,6 +853,7 @@ impl Function { native_family: Cell::new(0), wide_inline_facts: Cell::new(0), compact_inline_facts: Cell::new(0), + scope_bypassed: Cell::new(false), deopt_bindings: None, module_host: None, module_imports: Default::default(), @@ -888,6 +912,7 @@ impl Function { native_family: Cell::new(0), wide_inline_facts: Cell::new(0), compact_inline_facts: Cell::new(0), + scope_bypassed: Cell::new(false), deopt_bindings: None, module_host: None, module_imports: Default::default(), diff --git a/crates/qjs-runtime/src/tests/direct_eval.rs b/crates/qjs-runtime/src/tests/direct_eval.rs index 273aef22..b3be2e28 100644 --- a/crates/qjs-runtime/src/tests/direct_eval.rs +++ b/crates/qjs-runtime/src/tests/direct_eval.rs @@ -60,3 +60,38 @@ fn eval_created_closures_resolve_the_same_names_with_or_without_the_eval_scope() )) ); } + +/// A closure created in a function that has a direct `eval` bypasses that +/// function's dynamic scope only until the scope binds a new name: a later +/// `eval('var String = 5')` must then be visible to it. A generator's frame +/// keeps the scope throughout, and a frame creating many closures stops +/// bypassing past its limit. +#[test] +fn closures_see_names_a_later_eval_adds_to_their_function() { + assert_eq!( + eval( + "var out = []; + function later() { var g = function () { return typeof String; }; var r = g(); eval('var String = 5'); return r + ' ' + g(); } + function hoisted() { function g() { return typeof zz; } var r = g(); eval('var zz = 1'); return r + ' ' + g(); } + function captured() { var self = 3; function d() { return self + 1; } return eval('d()') + eval('d()'); } + function* suspended() { var g = function () { return typeof yy; }; yield g(); eval('var yy = 1'); yield g(); } + function many() { var fns = []; for (var i = 0; i < 70; i++) fns.push(function () { return typeof Math; }); eval('var Math = 1'); return fns[0]() + ' ' + fns[69](); } + function walkTree(filter) { + function walk(k, v) { var i; if (v && typeof v === 'object') { for (i in v) { var n = walk(i, v[i]); if (n !== undefined) v[i] = n; } } return filter(k, v); } + return JSON.stringify(walk('', eval('({a: [1, 2, {b: 3}], c: 4})'))); + } + for (var i = 0; i < 2; i++) { + var it = suspended(); + out.push(later(), hoisted(), captured(), it.next().value + ' ' + it.next().value, many(), + walkTree(function (k, v) { return typeof v === 'number' ? v * 10 : v; })); + } + out.join('|');" + ), + Ok(Value::String( + "function number|undefined number|8|undefined number|number number|{\"a\":[10,20,{\"b\":30}],\"c\":40}|" + .repeat(2) + .trim_end_matches('|') + .into() + )) + ); +} From f630504d04158ef54ff1a6a5e4126145ad35370d Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 08:06:50 -0700 Subject: [PATCH 4/5] Record the perf26 units, stack run and findings in T033 Co-Authored-By: Claude Opus 5.5 (1M context) --- tasks/T033-wide-tier-interpreter-exits.md | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tasks/T033-wide-tier-interpreter-exits.md b/tasks/T033-wide-tier-interpreter-exits.md index fbd74266..6888bc1c 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. +- perf26 units (6f500ce6..fe533cc7, branch agent/perf-25): a `for-in` + site remembers the prototype chain of its last ordinary target with the + keys that chain contributes, so a loop over many objects of one shape + enumerates own keys plus the unshadowed inherited ones without the + shadowing hash set (a two-key `for-in` 3599 -> 1346 cycles, NG 1082); the + wide tier's `for-in` exits answer ordinary objects without an environment. + Closures created by a function with a direct `eval` bypass its dynamic + scope while the scope binds none of their by-name names; the scope keeps + the list and revokes every bypass (with the memoized call-path facts) on + its next generation change, frames that can suspend are excluded, and at + most 64 closures bypass one scope (`Vm::closure_scope_use`, + `DynamicBindings::bypass`): string-tagcloud 0.784 (its JSON `walk`), + tofte 0.979. The numeric helper's register file is 64-byte aligned + (instruction count no longer depends on caller frame sizes) and + `NumProgram::run` is pinned. +- Stack run fe533cc7 vs main cebad5e9 (30 blocks, cycles, loaded host; + `target/comparison/perf26-fe533cc7-30b`): external geomean **0.995** + against main and **0.749 against QuickJS-NG**; string-tagcloud **0.759** + (1.31 -> 1.000 against NG), tofte 0.975, regexp-dna 0.992. Worst: + JetStream gaussian-blur 1.057 -- the same two binaries measure 1.001 by + min-of-5 alternation from /tmp, so this is the harness-path sensitivity + already recorded, not code; access-nsieve 1.018. Sentinels 0.993-1.002 + (recursive_call_tree 0.993: the 10% below did not reproduce here). +- Layout (2026-09-26): recursive_call_tree ran 10% more cycles with the + `for-in` unit at identical helper code and identical helper offset + (`NumProgram::run` pinned where main has it): its jump tables in + read-only data moved with other code's constants, which the order file + cannot place. With `MallocNanoZone=0` the gap was 2%. Other sentinels + flat. Min-of-N alternation (`/tmp/minab.sh`, `/tmp/minsent.sh`) resolved + these while the host was loaded; the screen tool's intervals did not. +- Found (2026-09-26): 3d-raytrace's `Scene.blocked`/`intersect` loops + compile to typed programs that decline at entry every time (`TLRUN ... + Declined`): they write the implicit global `i`, and + `WideLoopFrame::prepare_typed_loop_sloppy_global_write` always declines. + Supporting it would not help this case: the loop calls + `triangle.intersect`, not a closed-form leaf, so the program would + deoptimize at the call (`global_store_stays_interpreted` admits the body to + the wide tier for that reason). The loop's cost is the wide tier's own + (1.25x NG per triangle). - 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 From 5d1cf590082cb0bfe2e3679f339056b7dc1419dc Mon Sep 17 00:00:00 2001 From: qingyingliu Date: Sat, 26 Sep 2026 08:10:34 -0700 Subject: [PATCH 5/5] Move T033's earlier stack runs to an archive file The task file passed its 600-line limit; perf8 through perf20's stack runs and measurements move to tasks/archive/T033-screen-log-early.md unchanged, with a pointer in the screen log. Co-Authored-By: Claude Opus 5.5 (1M context) --- tasks/T033-wide-tier-interpreter-exits.md | 197 +-------------------- tasks/archive/T033-screen-log-early.md | 201 ++++++++++++++++++++++ 2 files changed, 203 insertions(+), 195 deletions(-) create mode 100644 tasks/archive/T033-screen-log-early.md diff --git a/tasks/T033-wide-tier-interpreter-exits.md b/tasks/T033-wide-tier-interpreter-exits.md index 6888bc1c..f795325a 100644 --- a/tasks/T033-wide-tier-interpreter-exits.md +++ b/tasks/T033-wide-tier-interpreter-exits.md @@ -116,201 +116,8 @@ Plan and evidence: `tasks/performance-units/wide-tier-interpreter-exits.json` Folding `typeof local` and `return local` in place on top: 1.001, closed. - Existing global variables assigned on the tier (7477de9e): fasta 0.80. -- Measured wide costs (instruction increments against QuickJS-NG): a - call-and-return 700-800 instructions vs 300; a cached named read about - 240 vs 50; `s = s + i` 95 vs 36. Typed loop programs run at parity with - NG's interpreter per operation (75 cycles per iteration on the same - 8-operation loop). - -- Stack run 819a6ba4 vs main 0c2b38f1 (30 blocks, cycles, quiet host; - `target/comparison/wide-calls-819a6ba4-30b`): peephole rewrites, global - variable stores, post-accelerator hand-back, Math.random in typed loops, - string concatenation in place, native family dispatch, ASCII case - mapping, array/string/number method caches, integer formatting, dense - slice/concat/sort with default species, RegExp exec/test prelude. - External geomean 0.951 against main (JetStream subset 0.933, Kraken - 0.939, SunSpider 0.960) and 1.0015 against QuickJS-NG in wall time; - stanford-crypto-pbkdf2/ccm, string-validate-input 0.872, - date-format-xparb 0.859, hash-map 0.879. Worst against main: - math-partial-sums 1.017, access-nsieve 1.016. Sentinel - `heterogeneous_property_read` 1.107 and broad `array_dynamic_read` 1.041 - are open. - -- Rejected: fusing a typed-loop comparison with the Exit/JumpIfFalsy that - tests it (`CompareSkip`, one new dispatch arm): corpus 1.0025, - access-fannkuch 1.03, math-partial-sums 1.02-1.09 with fewer - instructions -- the arm re-rolled the dispatch loop. Patch in - /tmp/typed-compare-skip.patch at the time. -- The implicit-global loop rule (eedfb4ae) measured 0.96-0.99 on - 3d-raytrace over repeats, not the 0.90 of its first run. - -- Stack run 7ea40cb2 vs main 986839a1 (30 blocks, cycles, quiet host; - `target/comparison/builtins2-7ea40cb2`): string relational compares - without an environment, memoized inlining facts, remembered receiver - misses, compound member assignment on the wide tier, accelerated-loop-only - global store declines, undeclared globals created on the tier, field - initializer member reads. External geomean 0.995 against main (JetStream - subset 0.991, Kraken 0.997, SunSpider 0.996) and **0.985 against - QuickJS-NG** in wall time -- the first formal run below it. - string-validate-input 0.928, stanford-crypto-ccm 0.950, crypto-md5 0.956, - 3d-raytrace 0.968, raytrace-public-class-fields 0.972, hash-map 0.980. - Worst against main: math-spectral-norm 1.031, imaging-gaussian-blur - 1.023, crypto-sha1 1.020. Sentinels 0.982-0.999; broad lane flat. - Largest remaining against NG: hash-map 2.16, tagcloud 2.03, 3d-raytrace - 1.94, ai-astar 1.82, controlflow-recursive 1.80, tofte/xparb 1.78, - raytrace-class-fields 1.77, nbody 1.76. - -- Stack run 99275d24 vs main 3daba0ec (30 blocks, cycles, quiet host; - `target/comparison/strings3-99275d24`): lazy String-wrapper index - properties, wide array-hole reads and the store/reload peephole, in-place - global string appends on the wide tier, String-wrapper ToPrimitive - without a call. External geomean 0.988 against main (SunSpider 0.978) - and **0.971 against QuickJS-NG**; string-tagcloud 0.769, - string-validate-input 0.825, date-format-xparb 0.895. Worst against - main: audio-dft 1.022, stanford-crypto-sha256-iterative 1.018. - Sentinels 0.960-1.003 (`prototype_method_call` 0.960). - -- Stack run 520a5734 vs main 9f13933a (30 blocks, cycles, quiet host; - `target/comparison/json-parse-520a5734`): JSON.stringify copying - unescaped runs, operator-token variant checks, typed-loop global writes - by slot (dynamic property storage now slot-addressed), nested literals - parsed once (the parser tried every `[`/`{` as a destructuring pattern - first -- exponential in nesting depth), for-in layers read from storage, - the cheap dynamic-realm predicate. External geomean 0.965 against main - and **0.938 against QuickJS-NG**; json-stringify-tinderbox 0.551, - math-partial-sums 0.771 (now 0.991 against NG), jetstream - stanford-crypto-aes 0.783, string-tagcloud 0.885. Worst against main: - xparb 1.021; sentinel `string_key_map_churn` 1.020 (dictionary churn pays - the slot index's upkeep on removal). - -- Stack run 745de05c vs main b3179386 (30 blocks, cycles, quiet host; - `target/comparison/calls4-745de05c`): wide call/return trims (packed - inlining facts, object receivers as `this`, slimmer frames, empty-register - skip; 1007 -> 894 instructions per call), strings and JSON text built - without per-value temporaries, repeated JSON.parse keys shared, RegExp - lastIndex written in place. External geomean 0.984 against main and - **0.922 against QuickJS-NG**; json-stringify-tinderbox 0.730, - json-parse-financial 0.831, crypto-md5 0.953, binary-trees 0.957, cdjs - 0.960, hash-map 0.970. Worst against main: access-nsieve 1.017. - Sentinels 0.9995-1.008. -- Stack run 26c1ba7a vs main b62e9326 (30 blocks, cycles, quiet host; - `target/comparison/perf5-26c1ba7a`): number-only callees evaluated on - typed-loop argument numbers, number-only leaves in register form, - typed-loop invariant reads hoisted to loop entry, home-object methods - and base-class `new` inlined on the wide tier. External geomean 0.980 - against main and **0.901 against QuickJS-NG**; bits-in-byte 0.701, - raytrace-public-class-fields 0.743, spectral-norm 0.869, ai-astar 0.874, - sha1 0.893. Worst against main: imaging-gaussian-blur 1.033, - imaging-desaturate 1.031. The sentinels regressed 2-3% - (`prototype_method_call` 1.031); bisected to e1f94e22, whose inline - number-only path made the compiler emit the typed loop's boxed argument - array drop out of line. Fixed in 1d7564cd (evaluation out of line, - argument array `ManuallyDrop`): sentinels 0.963 against main - (`polymorphic_call_site` 0.915), corpus 0.999 single-run. -- Stack run 4d6c5686 vs main e3cf0b51 (30 blocks, cycles, quiet host; - `target/comparison/perf6-4d6c5686`): prototype reads cached by slot in - dynamic prototype storage (a prototype past a dozen methods installed no - entry: 6,478 -> 2,425 instructions per method call through one), a write - cache on plain named assignments, a non-cloning global-object check per - named write, and bodies admitted whose globally-writing loop calls user - methods (3d-raytrace's `blocked` ran 1,320 times on the general path). - External geomean 0.994 against main and **0.900 against QuickJS-NG**; - 3d-raytrace 0.916, string-fasta 0.947, math-cordic 0.964, xparb 0.971, - raytrace-public-class-fields 0.979. Worst against main: access-nsieve - 1.023. Sentinels 0.992-1.000. -- Measured (instructions per call, micro, ours vs QuickJS-NG): plain call - 677/273, method call 1080/441, own read ~185/80, prototype read 323/103, - own write ~300/71 (two plain op dispatches alone ~140); entering a typed - loop from a wide exit ~3,900; a typed iteration of `o.x += o.y * i` - 722/305. nbody enters three typed programs per `advance` call. -- Stack run 72c84fa8 vs main 1d9a5ec3 (30 blocks, cycles, quiet host; - `target/comparison/perf7-72c84fa8`): short loops kept on the wide tier - instead of entering their typed program, the compact tier's inlining - proof memoized on the function, and a fixed typed-loop scalar register - file indexed without bounds checks. External geomean 0.988 against main - and **0.889 against QuickJS-NG**; imaging-gaussian-blur 0.944, 3d-morph - 0.952, access-nsieve 0.953, controlflow-recursive 0.956. Worst against - main: 3d-raytrace 1.011, tofte 1.010. Sentinels 0.911-0.999 - (heterogeneous_property_read 0.911). -- Stack run 3214fdce vs main 5bcca07f (`target/comparison/perf8-3214fdce`) - measured 0.988 against main and 0.879 against QuickJS-NG, but with the - call sentinels +15-22% and ai-astar +18% at equal instructions: a stale - `hot-functions.order` (see docs/performance-knowledge.md). Regenerated in - the next commit; single-run then corpus 0.982, sentinels 0.999, ai-astar - 1.006 against the same base. -- Stack run c14c22b1 vs main 5bcca07f (30 blocks, cycles, quiet host; - `target/comparison/perf8b-c14c22b1`): element reads without cloning the - array, the eval overlay memo, the typed-loop operand stack rebuilt in - place, loose string equality, and the regenerated order file. External - geomean 0.984 against main and **0.876 against QuickJS-NG**; tofte 0.883, - 3d-raytrace 0.896, crypto-aes 0.914, bits-in-byte 0.924. Worst against - main: string-unpack-code 1.009. Sentinels 0.995-1.005. -- Stack run 357d81c3 vs main e7c34545 (30 blocks, cycles, quiet host; - `target/comparison/perf9-357d81c3`): helpers with loops, and numeric - helpers on f64 registers. External geomean 0.992 against main and - **0.869 against QuickJS-NG**; bits-in-byte 0.656 (0.997 against NG, - from 1.52). Worst against main: imaging-gaussian-blur 1.032. - Sentinels 0.994-0.999 except recursive_call_tree 1.040 (identical - instructions; the grown helper interpreter's placement -- every other - placement tried moved ai-astar or the call sentinels 18-25%). -- Stack run 59890450 vs main fb08d251 (30 blocks, cycles, quiet host; - `target/comparison/perf10-59890450`): numeric call trees on f64 - registers. External geomean 0.993 against main and **0.866 against - QuickJS-NG**; controlflow-recursive 0.563 (0.984 against NG, from 1.89). - ai-astar 1.193 and the call sentinels 1.15-1.22 (recursive_call_tree - 0.966) with identical instruction counts: main fb08d251 sits in a lucky - layout of the typed-loop executor's callees that every edit tried lost -- - only typed_loop's helper changes, the order file regenerated, one - codegen unit (main itself: ai-astar 9270 vs 8276 M cycles), 64-byte - function alignment (both worse). Merged on the external aggregate; the - layout sensitivity is the open item below. -- Stack run d8a98ca3 vs main 6968f738 (30 blocks, cycles, quiet host; - `target/comparison/perf11-d8a98ca3`): numeric call chains from wide - calls, allocation-free plan runs. External geomean 0.993 against main and - **0.860 against QuickJS-NG**; crypto-md5 0.785, ai-astar 0.840 and the - call sentinels 0.84-0.93 (the layout of perf10 rolled back, identical - instructions). Worst against main: string-unpack-code 1.047. -- Numeric plans lowered from bytecode (perf13, 2026-09-24, - `compact_fn/numeric_plan/from_bytecode.rs`): bodies outside the compact - tier are interpreted abstractly under number arguments -- `typeof` - folds, string comparisons fold, unreachable cases are never lowered, and - a reachable path the encoding cannot hold ends in `NumOp::Bail` (hand - back). hash-map's `computeHashCode`/`equals` run on plans: hash-map - 0.913, corpus 0.9964 single-run, canaries flat. Plans may now return - booleans (only to a root caller, never inside a call chain). Found on the - way (fixed in e2c08594): a plan or numeric helper called with fewer - arguments than parameters read `undefined` as a number (`f()` with - `a === b` returned 2, QuickJS-NG 1). -- Stack run 4a8b31f3 vs main 49d8c58d (30 blocks, cycles, quiet host; - `target/comparison/perf13-4a8b31f3`): numeric plans from bytecode, leaf - plans on a stack array, the pinned executor address. External geomean - 0.995 against main and **0.853 against QuickJS-NG**; hash-map 0.859, - math-cordic 0.937, controlflow-recursive 0.971. Worst against main: - string-unpack-code 1.024; sentinels 0.94-1.04. -- Callbacks (980c95fe, 6f5388fa): `arr.forEach(function (x) { total += x; - })` ran 9.5x slower than QuickJS-NG -- the callback assigns a captured - variable, so the wide tier declined it (the received-cell proof was - read-only) and every call built an interpreter `Vm`; and `call_function` - (every native's callback path) built a compatibility frame environment - per call. Received cells a body only reads or plainly assigns are now - written through the cell (`cell_received_upvalue_slots`, - `StoreUpvalueLocal`; loops keep the interpreter), and natives call direct - leaves the interpreter's way (`call_direct_leaf_function`). forEach - 1,350M -> 595M cycles (NG 133M); string-unpack-code 0.84 single-run. - Remaining: the per-call argument `Vec` in array iteration, the closed-form - probes before the tiers, and the wide entry's storage swap. -- Callback and global-variable costs (perf20, cb708c9c..0f6349a2): the - forEach-with-a-global-accumulator micro was 3.3x QuickJS-NG after the - callback units; sampling split it into the argument Vec per call - (`call_function_slice` passes a direct leaf a slice, 0.770), the element - read resolving Array.prototype by name per element - (`plain_dense_index_value`, 0.905), `LoadGlobal` hashing its name per - read (a per-site realm-cell memo keyed on the realm table's generation, - 0.936; validate-input 0.959) and the global store cloning then re-finding - the globalThis property (`write_existing_own_data_property_if`, 0.859; - validate-input 0.977). Also pinned `run` (see - docs/performance-knowledge.md): unpinned, an unrelated edit cost - math-partial-sums 4.7%. Corpus screen 0.993 single-run. +- Earlier stack runs and measurements (perf8 through perf20) are in + `tasks/archive/T033-screen-log-early.md`. - perf21 units (c096993c..e73471e6): a cached direct eval that writes and deletes no binding skips the caller's frame write-back (apply_env, 8% of date-format-tofte; tofte 0.917); run pinned ahead of the wide diff --git a/tasks/archive/T033-screen-log-early.md b/tasks/archive/T033-screen-log-early.md new file mode 100644 index 00000000..c73918e8 --- /dev/null +++ b/tasks/archive/T033-screen-log-early.md @@ -0,0 +1,201 @@ +# T033 screen log: earlier stack runs and measurements + +Moved from `tasks/T033-wide-tier-interpreter-exits.md` (Screen log) to keep +the task file under the task-file size limit. Entries are in their +original order; later entries are in the task file. + +- Measured wide costs (instruction increments against QuickJS-NG): a + call-and-return 700-800 instructions vs 300; a cached named read about + 240 vs 50; `s = s + i` 95 vs 36. Typed loop programs run at parity with + NG's interpreter per operation (75 cycles per iteration on the same + 8-operation loop). + +- Stack run 819a6ba4 vs main 0c2b38f1 (30 blocks, cycles, quiet host; + `target/comparison/wide-calls-819a6ba4-30b`): peephole rewrites, global + variable stores, post-accelerator hand-back, Math.random in typed loops, + string concatenation in place, native family dispatch, ASCII case + mapping, array/string/number method caches, integer formatting, dense + slice/concat/sort with default species, RegExp exec/test prelude. + External geomean 0.951 against main (JetStream subset 0.933, Kraken + 0.939, SunSpider 0.960) and 1.0015 against QuickJS-NG in wall time; + stanford-crypto-pbkdf2/ccm, string-validate-input 0.872, + date-format-xparb 0.859, hash-map 0.879. Worst against main: + math-partial-sums 1.017, access-nsieve 1.016. Sentinel + `heterogeneous_property_read` 1.107 and broad `array_dynamic_read` 1.041 + are open. + +- Rejected: fusing a typed-loop comparison with the Exit/JumpIfFalsy that + tests it (`CompareSkip`, one new dispatch arm): corpus 1.0025, + access-fannkuch 1.03, math-partial-sums 1.02-1.09 with fewer + instructions -- the arm re-rolled the dispatch loop. Patch in + /tmp/typed-compare-skip.patch at the time. +- The implicit-global loop rule (eedfb4ae) measured 0.96-0.99 on + 3d-raytrace over repeats, not the 0.90 of its first run. + +- Stack run 7ea40cb2 vs main 986839a1 (30 blocks, cycles, quiet host; + `target/comparison/builtins2-7ea40cb2`): string relational compares + without an environment, memoized inlining facts, remembered receiver + misses, compound member assignment on the wide tier, accelerated-loop-only + global store declines, undeclared globals created on the tier, field + initializer member reads. External geomean 0.995 against main (JetStream + subset 0.991, Kraken 0.997, SunSpider 0.996) and **0.985 against + QuickJS-NG** in wall time -- the first formal run below it. + string-validate-input 0.928, stanford-crypto-ccm 0.950, crypto-md5 0.956, + 3d-raytrace 0.968, raytrace-public-class-fields 0.972, hash-map 0.980. + Worst against main: math-spectral-norm 1.031, imaging-gaussian-blur + 1.023, crypto-sha1 1.020. Sentinels 0.982-0.999; broad lane flat. + Largest remaining against NG: hash-map 2.16, tagcloud 2.03, 3d-raytrace + 1.94, ai-astar 1.82, controlflow-recursive 1.80, tofte/xparb 1.78, + raytrace-class-fields 1.77, nbody 1.76. + +- Stack run 99275d24 vs main 3daba0ec (30 blocks, cycles, quiet host; + `target/comparison/strings3-99275d24`): lazy String-wrapper index + properties, wide array-hole reads and the store/reload peephole, in-place + global string appends on the wide tier, String-wrapper ToPrimitive + without a call. External geomean 0.988 against main (SunSpider 0.978) + and **0.971 against QuickJS-NG**; string-tagcloud 0.769, + string-validate-input 0.825, date-format-xparb 0.895. Worst against + main: audio-dft 1.022, stanford-crypto-sha256-iterative 1.018. + Sentinels 0.960-1.003 (`prototype_method_call` 0.960). + +- Stack run 520a5734 vs main 9f13933a (30 blocks, cycles, quiet host; + `target/comparison/json-parse-520a5734`): JSON.stringify copying + unescaped runs, operator-token variant checks, typed-loop global writes + by slot (dynamic property storage now slot-addressed), nested literals + parsed once (the parser tried every `[`/`{` as a destructuring pattern + first -- exponential in nesting depth), for-in layers read from storage, + the cheap dynamic-realm predicate. External geomean 0.965 against main + and **0.938 against QuickJS-NG**; json-stringify-tinderbox 0.551, + math-partial-sums 0.771 (now 0.991 against NG), jetstream + stanford-crypto-aes 0.783, string-tagcloud 0.885. Worst against main: + xparb 1.021; sentinel `string_key_map_churn` 1.020 (dictionary churn pays + the slot index's upkeep on removal). + +- Stack run 745de05c vs main b3179386 (30 blocks, cycles, quiet host; + `target/comparison/calls4-745de05c`): wide call/return trims (packed + inlining facts, object receivers as `this`, slimmer frames, empty-register + skip; 1007 -> 894 instructions per call), strings and JSON text built + without per-value temporaries, repeated JSON.parse keys shared, RegExp + lastIndex written in place. External geomean 0.984 against main and + **0.922 against QuickJS-NG**; json-stringify-tinderbox 0.730, + json-parse-financial 0.831, crypto-md5 0.953, binary-trees 0.957, cdjs + 0.960, hash-map 0.970. Worst against main: access-nsieve 1.017. + Sentinels 0.9995-1.008. +- Stack run 26c1ba7a vs main b62e9326 (30 blocks, cycles, quiet host; + `target/comparison/perf5-26c1ba7a`): number-only callees evaluated on + typed-loop argument numbers, number-only leaves in register form, + typed-loop invariant reads hoisted to loop entry, home-object methods + and base-class `new` inlined on the wide tier. External geomean 0.980 + against main and **0.901 against QuickJS-NG**; bits-in-byte 0.701, + raytrace-public-class-fields 0.743, spectral-norm 0.869, ai-astar 0.874, + sha1 0.893. Worst against main: imaging-gaussian-blur 1.033, + imaging-desaturate 1.031. The sentinels regressed 2-3% + (`prototype_method_call` 1.031); bisected to e1f94e22, whose inline + number-only path made the compiler emit the typed loop's boxed argument + array drop out of line. Fixed in 1d7564cd (evaluation out of line, + argument array `ManuallyDrop`): sentinels 0.963 against main + (`polymorphic_call_site` 0.915), corpus 0.999 single-run. +- Stack run 4d6c5686 vs main e3cf0b51 (30 blocks, cycles, quiet host; + `target/comparison/perf6-4d6c5686`): prototype reads cached by slot in + dynamic prototype storage (a prototype past a dozen methods installed no + entry: 6,478 -> 2,425 instructions per method call through one), a write + cache on plain named assignments, a non-cloning global-object check per + named write, and bodies admitted whose globally-writing loop calls user + methods (3d-raytrace's `blocked` ran 1,320 times on the general path). + External geomean 0.994 against main and **0.900 against QuickJS-NG**; + 3d-raytrace 0.916, string-fasta 0.947, math-cordic 0.964, xparb 0.971, + raytrace-public-class-fields 0.979. Worst against main: access-nsieve + 1.023. Sentinels 0.992-1.000. +- Measured (instructions per call, micro, ours vs QuickJS-NG): plain call + 677/273, method call 1080/441, own read ~185/80, prototype read 323/103, + own write ~300/71 (two plain op dispatches alone ~140); entering a typed + loop from a wide exit ~3,900; a typed iteration of `o.x += o.y * i` + 722/305. nbody enters three typed programs per `advance` call. +- Stack run 72c84fa8 vs main 1d9a5ec3 (30 blocks, cycles, quiet host; + `target/comparison/perf7-72c84fa8`): short loops kept on the wide tier + instead of entering their typed program, the compact tier's inlining + proof memoized on the function, and a fixed typed-loop scalar register + file indexed without bounds checks. External geomean 0.988 against main + and **0.889 against QuickJS-NG**; imaging-gaussian-blur 0.944, 3d-morph + 0.952, access-nsieve 0.953, controlflow-recursive 0.956. Worst against + main: 3d-raytrace 1.011, tofte 1.010. Sentinels 0.911-0.999 + (heterogeneous_property_read 0.911). +- Stack run 3214fdce vs main 5bcca07f (`target/comparison/perf8-3214fdce`) + measured 0.988 against main and 0.879 against QuickJS-NG, but with the + call sentinels +15-22% and ai-astar +18% at equal instructions: a stale + `hot-functions.order` (see docs/performance-knowledge.md). Regenerated in + the next commit; single-run then corpus 0.982, sentinels 0.999, ai-astar + 1.006 against the same base. +- Stack run c14c22b1 vs main 5bcca07f (30 blocks, cycles, quiet host; + `target/comparison/perf8b-c14c22b1`): element reads without cloning the + array, the eval overlay memo, the typed-loop operand stack rebuilt in + place, loose string equality, and the regenerated order file. External + geomean 0.984 against main and **0.876 against QuickJS-NG**; tofte 0.883, + 3d-raytrace 0.896, crypto-aes 0.914, bits-in-byte 0.924. Worst against + main: string-unpack-code 1.009. Sentinels 0.995-1.005. +- Stack run 357d81c3 vs main e7c34545 (30 blocks, cycles, quiet host; + `target/comparison/perf9-357d81c3`): helpers with loops, and numeric + helpers on f64 registers. External geomean 0.992 against main and + **0.869 against QuickJS-NG**; bits-in-byte 0.656 (0.997 against NG, + from 1.52). Worst against main: imaging-gaussian-blur 1.032. + Sentinels 0.994-0.999 except recursive_call_tree 1.040 (identical + instructions; the grown helper interpreter's placement -- every other + placement tried moved ai-astar or the call sentinels 18-25%). +- Stack run 59890450 vs main fb08d251 (30 blocks, cycles, quiet host; + `target/comparison/perf10-59890450`): numeric call trees on f64 + registers. External geomean 0.993 against main and **0.866 against + QuickJS-NG**; controlflow-recursive 0.563 (0.984 against NG, from 1.89). + ai-astar 1.193 and the call sentinels 1.15-1.22 (recursive_call_tree + 0.966) with identical instruction counts: main fb08d251 sits in a lucky + layout of the typed-loop executor's callees that every edit tried lost -- + only typed_loop's helper changes, the order file regenerated, one + codegen unit (main itself: ai-astar 9270 vs 8276 M cycles), 64-byte + function alignment (both worse). Merged on the external aggregate; the + layout sensitivity is the open item below. +- Stack run d8a98ca3 vs main 6968f738 (30 blocks, cycles, quiet host; + `target/comparison/perf11-d8a98ca3`): numeric call chains from wide + calls, allocation-free plan runs. External geomean 0.993 against main and + **0.860 against QuickJS-NG**; crypto-md5 0.785, ai-astar 0.840 and the + call sentinels 0.84-0.93 (the layout of perf10 rolled back, identical + instructions). Worst against main: string-unpack-code 1.047. +- Numeric plans lowered from bytecode (perf13, 2026-09-24, + `compact_fn/numeric_plan/from_bytecode.rs`): bodies outside the compact + tier are interpreted abstractly under number arguments -- `typeof` + folds, string comparisons fold, unreachable cases are never lowered, and + a reachable path the encoding cannot hold ends in `NumOp::Bail` (hand + back). hash-map's `computeHashCode`/`equals` run on plans: hash-map + 0.913, corpus 0.9964 single-run, canaries flat. Plans may now return + booleans (only to a root caller, never inside a call chain). Found on the + way (fixed in e2c08594): a plan or numeric helper called with fewer + arguments than parameters read `undefined` as a number (`f()` with + `a === b` returned 2, QuickJS-NG 1). +- Stack run 4a8b31f3 vs main 49d8c58d (30 blocks, cycles, quiet host; + `target/comparison/perf13-4a8b31f3`): numeric plans from bytecode, leaf + plans on a stack array, the pinned executor address. External geomean + 0.995 against main and **0.853 against QuickJS-NG**; hash-map 0.859, + math-cordic 0.937, controlflow-recursive 0.971. Worst against main: + string-unpack-code 1.024; sentinels 0.94-1.04. +- Callbacks (980c95fe, 6f5388fa): `arr.forEach(function (x) { total += x; + })` ran 9.5x slower than QuickJS-NG -- the callback assigns a captured + variable, so the wide tier declined it (the received-cell proof was + read-only) and every call built an interpreter `Vm`; and `call_function` + (every native's callback path) built a compatibility frame environment + per call. Received cells a body only reads or plainly assigns are now + written through the cell (`cell_received_upvalue_slots`, + `StoreUpvalueLocal`; loops keep the interpreter), and natives call direct + leaves the interpreter's way (`call_direct_leaf_function`). forEach + 1,350M -> 595M cycles (NG 133M); string-unpack-code 0.84 single-run. + Remaining: the per-call argument `Vec` in array iteration, the closed-form + probes before the tiers, and the wide entry's storage swap. +- Callback and global-variable costs (perf20, cb708c9c..0f6349a2): the + forEach-with-a-global-accumulator micro was 3.3x QuickJS-NG after the + callback units; sampling split it into the argument Vec per call + (`call_function_slice` passes a direct leaf a slice, 0.770), the element + read resolving Array.prototype by name per element + (`plain_dense_index_value`, 0.905), `LoadGlobal` hashing its name per + read (a per-site realm-cell memo keyed on the realm table's generation, + 0.936; validate-input 0.959) and the global store cloning then re-finding + the globalThis property (`write_existing_own_data_property_if`, 0.859; + validate-input 0.977). Also pinned `run` (see + docs/performance-knowledge.md): unpinned, an unrelated edit cost + math-partial-sums 4.7%. Corpus screen 0.993 single-run.