diff --git a/crates/qjs-cli/hot-functions.order b/crates/qjs-cli/hot-functions.order index 4fbd9c76..e7b00de4 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 56 __RNvMsz_NtNtNtCs1OjIl8oxbrv_5alloc11collections5btree3mapINtB5_8IntoIteryNtNtNtCscUtGwbhD4WH_5gimli4read6abbrev12AbbreviationE10dying_nextCsg55jX0GwzBC_3std __RNvXs7_NtNtCsg55jX0GwzBC_3std2io5errorNtB5_5ErrorNtNtCsl8K0bEFm1U0_4core3fmt7Display3fmt __RINvNtNtNtCs9nYd1Hk1rek_11qjs_runtime8bytecode10typed_loop7execute3runNtNtB6_2vm2VmEB8_ @@ -28,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 @@ -38,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 @@ -62,11 +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 +__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_ __RNvYINtNvNtCsg55jX0GwzBC_3std2io17default_write_fmt7AdapterNtNtNtNtB9_3sys5stdio4unix6StderrENtNtCsl8K0bEFm1U0_4core3fmt5Write10write_charB9_ -__RNvMs4_NtCs1OjIl8oxbrv_5alloc7raw_vecINtB5_11RawVecInnerNtNtCsg55jX0GwzBC_3std5alloc6SystemE14grow_amortizedBW_ -__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 @@ -221,7 +224,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/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/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/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/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/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 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() + )) + ); +} diff --git a/tasks/T033-wide-tier-interpreter-exits.md b/tasks/T033-wide-tier-interpreter-exits.md index fbd74266..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 @@ -342,6 +149,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 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. 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.