diff --git a/crates/perry-runtime/src/dyn_eval/bench.rs b/crates/perry-runtime/src/dyn_eval/bench.rs new file mode 100644 index 0000000000..b206b24260 --- /dev/null +++ b/crates/perry-runtime/src/dyn_eval/bench.rs @@ -0,0 +1,212 @@ +//! Measurement harness for the #6559 interpreter (#6693 perf work). +//! +//! Not a correctness test — a stopwatch. It mirrors the real TypeBox / ajv / +//! fastify load: construct many `new Function(source)` validators, then run +//! each many times. It reports the two costs the issue's `sample` split apart +//! — CONSTRUCTION (SWC parse + prepass, `swc_ecma_parser` frames) vs +//! EXECUTION (the tree-walk, `get_field_by_name` frames) — so an optimization +//! can be pointed at the dominant one and re-measured. +//! +//! Run explicitly (it is `#[ignore]`d so normal `cargo test` skips it): +//! ```text +//! cargo test --release -p perry-runtime --lib -- --ignored --nocapture \ +//! --test-threads=1 dyn_eval::bench +//! ``` +//! `PERRY_BENCH_N` (distinct validators, default 200) and `PERRY_BENCH_M` +//! (calls per validator, default 50) tune the load. + +use std::time::Instant; + +use super::dyn_function_from_strings; +use super::{root_get, root_push, roots_truncate}; + +fn call(f: f64, args: &[f64]) -> f64 { + unsafe { crate::closure::js_native_call_value(f, args.as_ptr(), args.len()) } +} + +fn env_usize(key: &str, default: usize) -> usize { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// A TypeBox-`TypeCompiler`-shaped object validator: the exact construct mix +/// the real workload emits — scope-chain identifier reads (`value`, `ok`), +/// named property access (`value.fN`), typeof guards, relational/logical ops, +/// a `for-in` excess-key scan. `seed` makes every source textually distinct +/// (real schemas are), so nothing dedups the parse away. +fn make_validator_source(seed: usize, fields: usize) -> String { + let mut s = String::new(); + s.push_str(&format!("/* validator {seed} */\n")); + s.push_str("return function check(value) {\n"); + s.push_str(" let ok = true;\n"); + s.push_str(" ok = ok && (typeof value === 'object' && value !== null);\n"); + for i in 0..fields { + match i % 3 { + 0 => s.push_str(&format!( + " ok = ok && (typeof value.f{i} === 'number' && value.f{i} >= 0);\n" + )), + 1 => s.push_str(&format!( + " ok = ok && (typeof value.f{i} === 'string' && value.f{i}.length < 64);\n" + )), + _ => s.push_str(&format!( + " ok = ok && (typeof value.f{i} === 'boolean');\n" + )), + } + } + s.push_str(" let extra = 0;\n"); + s.push_str(" for (const k in value) { extra = extra + 1; }\n"); + s.push_str(" return ok && extra >= 0;\n"); + s.push_str("}\n"); + s +} + +/// A matching sample input `{ f0: 1, f1: "x", f2: true, … }`, built via the +/// interpreter itself so no test-only object plumbing is needed. +fn make_sample(fields: usize) -> f64 { + let mut src = String::from("return {"); + for i in 0..fields { + if i > 0 { + src.push(','); + } + match i % 3 { + 0 => src.push_str(&format!("f{i}:{i}")), + 1 => src.push_str(&format!("f{i}:\"s{i}\"")), + _ => src.push_str(&format!("f{i}:true")), + } + } + src.push('}'); + let maker = dyn_function_from_strings(&[src]); + call(maker, &[]) +} + +/// Measure CONSTRUCTION (SWC parse + prepass) on the REAL TypeBox validator +/// bodies captured from `pi-bundle.mjs` (the accel-ON bundle) via a +/// `Function`-constructor hook. Point `PERRY_BENCH_SRC_DIR` at a directory of +/// `.body` (+ optional `.params`) files. Reports cold parse time +/// and the warm (parse-cache-hit) time per source. Execution is not measured +/// here — the real bodies close over host scope params we don't have. +#[test] +#[ignore = "benchmark: set PERRY_BENCH_SRC_DIR and run with --ignored --nocapture"] +fn bench_real_sources() { + let Ok(dir) = std::env::var("PERRY_BENCH_SRC_DIR") else { + eprintln!("PERRY_BENCH_SRC_DIR not set — skipping real-source bench"); + return; + }; + let mut entries: Vec<_> = std::fs::read_dir(&dir) + .expect("read src dir") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().map(|x| x == "body").unwrap_or(false)) + .collect(); + entries.sort(); + + let mut total_cold = std::time::Duration::ZERO; + let mut total_warm = std::time::Duration::ZERO; + eprintln!( + "── #6693 real-source construction bench ({} bodies) ──", + entries.len() + ); + for body_path in &entries { + let body = std::fs::read_to_string(body_path).unwrap_or_default(); + let params_path = body_path.with_extension("params"); + let params = std::fs::read_to_string(¶ms_path).unwrap_or_default(); + let mut args: Vec = params + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + args.push(body.clone()); + + // Cold: first construction of this exact source (full parse + prepass). + let t = Instant::now(); + let f = dyn_function_from_strings(&args); + let cold = t.elapsed(); + std::hint::black_box(f); + // Warm: identical source again — should hit the parse cache. + let t = Instant::now(); + let f2 = dyn_function_from_strings(&args); + let warm = t.elapsed(); + std::hint::black_box(f2); + + total_cold += cold; + total_warm += warm; + eprintln!( + " {:>7} bytes : cold {:>8.3} ms warm {:>8.4} ms ({})", + body.len(), + cold.as_secs_f64() * 1e3, + warm.as_secs_f64() * 1e3, + body_path.file_name().unwrap().to_string_lossy() + ); + } + eprintln!( + "TOTAL cold parse: {:>8.2} ms TOTAL warm (cached): {:>8.4} ms", + total_cold.as_secs_f64() * 1e3, + total_warm.as_secs_f64() * 1e3 + ); +} + +#[test] +#[ignore = "benchmark: run explicitly with --ignored --nocapture"] +fn bench_typebox_like_load() { + let n = env_usize("PERRY_BENCH_N", 200); + let m = env_usize("PERRY_BENCH_M", 50); + let fields = env_usize("PERRY_BENCH_FIELDS", 12); + + let sample = make_sample(fields); + let sample_idx = root_push(sample); + + // ── Scenario 1: N DISTINCT sources (parse cannot be reused across them). ─ + let sources: Vec = (0..n).map(|i| make_validator_source(i, fields)).collect(); + + let t0 = Instant::now(); + let mut fns_idx = Vec::with_capacity(n); + for src in &sources { + let f = dyn_function_from_strings(std::slice::from_ref(src)); + fns_idx.push(root_push(f)); + } + let construct = t0.elapsed(); + + let t1 = Instant::now(); + let mut sink = 0u64; + for _ in 0..m { + for &fi in &fns_idx { + let r = call(root_get(fi), &[root_get(sample_idx)]); + sink = sink.wrapping_add(r.to_bits()); + } + } + let execute = t1.elapsed(); + + // ── Scenario 2: the SAME source constructed N times (parse-cache probe). ─ + let one = make_validator_source(0, fields); + let t2 = Instant::now(); + for _ in 0..n { + let f = dyn_function_from_strings(std::slice::from_ref(&one)); + std::hint::black_box(f); + } + let construct_same = t2.elapsed(); + + roots_truncate(sample_idx); + + let per_distinct_us = construct.as_micros() as f64 / n as f64; + let per_same_us = construct_same.as_micros() as f64 / n as f64; + let calls = (n * m) as f64; + let per_call_us = execute.as_micros() as f64 / calls; + + eprintln!("── #6693 dyn_eval bench (N={n} distinct, M={m} calls, fields={fields}) ──"); + eprintln!( + "CONSTRUCT {n} distinct : {:>8.2} ms total ({per_distinct_us:>7.2} us / new Function)", + construct.as_secs_f64() * 1e3 + ); + eprintln!( + "CONSTRUCT {n} SAME src : {:>8.2} ms total ({per_same_us:>7.2} us / new Function)", + construct_same.as_secs_f64() * 1e3 + ); + eprintln!( + "EXECUTE {} calls : {:>8.2} ms total ({per_call_us:>7.2} us / call)", + calls as u64, + execute.as_secs_f64() * 1e3 + ); + eprintln!("(sink={sink:x})"); +} diff --git a/crates/perry-runtime/src/dyn_eval/bridge.rs b/crates/perry-runtime/src/dyn_eval/bridge.rs index f836b902c7..faaccba202 100644 --- a/crates/perry-runtime/src/dyn_eval/bridge.rs +++ b/crates/perry-runtime/src/dyn_eval/bridge.rs @@ -11,8 +11,80 @@ //! runtime closure, so host code dispatches into it through the exact same //! towers. +use std::cell::RefCell; +use std::collections::HashMap; + use super::{root_get, root_push, roots_truncate}; +thread_local! { + /// Property name → its canonical INTERNED `StringHeader`. The interpreter's + /// member read (`get_member`) went through `js_get_property`, which + /// allocates a fresh, NON-interned key every call — and a non-interned key + /// makes the object getter's inline-cache fast lane bail to the full slow + /// scan (the lane gates on `GC_FLAG_INTERNED`). For property-heavy codegen + /// (TypeBox's `value.providers` / `value.name` / … millions of reads) that + /// is the dominant `get_field_by_name` cost (#6693). Interning each name + /// once (in the longlived arena, then registered canonical) lets repeated + /// reads hit the read-plan IC. Rooted by `scan_member_key_cache_mut`. + static MEMBER_KEY_CACHE: RefCell, *const crate::string::StringHeader>> = + RefCell::new(HashMap::new()); +} + +/// Canonical interned key for `name` (cached). First use allocates it in the +/// longlived arena and interns it; the canonical pointer is reused thereafter. +fn interned_member_key(name: &str) -> *const crate::string::StringHeader { + if let Some(ptr) = MEMBER_KEY_CACHE.with(|c| c.borrow().get(name).copied()) { + return ptr; + } + let ll = crate::string::js_string_from_bytes_longlived(name.as_ptr(), name.len() as u32); + let hash = crate::object::key_content_hash(ll); + let canonical = crate::string::js_string_intern(ll, hash); + MEMBER_KEY_CACHE.with(|c| { + c.borrow_mut().insert(name.into(), canonical); + }); + canonical +} + +/// Mark + rewrite the cached interned member keys (they may be canonicals in a +/// moving arena, unlike the always-longlived env keys, so the rewrite matters). +pub(super) fn scan_member_key_cache_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + MEMBER_KEY_CACHE.with(|c| { + for ptr in c.borrow_mut().values_mut() { + visitor.visit_tagged_raw_const_ptr_slot(ptr, crate::value::STRING_TAG); + } + }); +} + +/// #6693 fast member read (gated by `PERRY_DYN_FAST_SCOPE`): for a plain heap +/// object receiver, read the field with a cached INTERNED key so the object +/// getter's inline-cache fast lane engages, skipping the generic +/// `js_dynamic_object_get_property` receiver-dispatch cascade + per-call key +/// allocation. Returns `None` (→ generic path) for any receiver that isn't a +/// plain arena `GC_TYPE_OBJECT` — the generic read is authoritative for +/// strings / arrays / handles / proxies / closures / errors. For a plain +/// object the generic path ends in the very same `js_object_get_field_by_name_f64`, +/// so results are identical. +fn fast_object_get(base: f64, name: &str) -> Option { + let jv = crate::value::JSValue::from_bits(base.to_bits()); + if !jv.is_pointer() { + return None; + } + let addr = crate::value::js_nanbox_get_pointer(base) as usize; + if crate::value::addr_class::is_handle_band(addr) { + return None; + } + let h = unsafe { crate::value::addr_class::try_read_gc_header(addr) }?; + if h.obj_type != crate::gc::GC_TYPE_OBJECT { + return None; + } + let key = interned_member_key(name); + let v = crate::object::js_object_get_field_by_name_f64( + addr as *const crate::object::ObjectHeader, + key, + ); + Some(f64::from_bits(v.to_bits())) +} + pub(crate) fn undefined() -> f64 { f64::from_bits(crate::value::TAG_UNDEFINED) } @@ -124,6 +196,11 @@ pub(crate) fn get_member(base: f64, name: &str) -> f64 { if name == "length" { return crate::value::js_value_length_f64(base); } + if super::fast_scope_enabled() { + if let Some(v) = fast_object_get(base, name) { + return v; + } + } unsafe { crate::value::js_get_property(base, name.as_ptr() as i64, name.len() as i64) } } diff --git a/crates/perry-runtime/src/dyn_eval/env.rs b/crates/perry-runtime/src/dyn_eval/env.rs index dde6e0bd0e..027984c221 100644 --- a/crates/perry-runtime/src/dyn_eval/env.rs +++ b/crates/perry-runtime/src/dyn_eval/env.rs @@ -8,6 +8,9 @@ //! relocate scopes like any other object (no Rust-side pointer can go stale //! because every held value routes through the rooted stack in `mod.rs`). +use std::cell::RefCell; +use std::collections::HashMap; + use super::{root_get, root_push, root_set, roots_truncate}; /// Parent-scope key. Contains a space, so no declared identifier can ever @@ -15,8 +18,55 @@ use super::{root_get, root_push, root_set, roots_truncate}; /// identifier resolution, never via computed access). const PARENT_KEY: &str = "perry dyn parent"; -fn key_string(name: &str) -> *mut crate::string::StringHeader { - crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +thread_local! { + /// identifier name → its cached `StringHeader`. Every scope-chain read / + /// write allocated a fresh heap `StringHeader` for the key on the old + /// path (`js_string_from_bytes` never SSO-inlines), so a hot validator + /// that touches `value` / `ok` / a loop var thousands of times burned a + /// heap allocation per access — a top #6693 execution cost. Env keys are + /// the same small vocabulary reused forever, so we allocate each once in + /// the LONGLIVED arena (stable pointer for the thread's life, never + /// swept/moved — issue #179, the `PARSE_KEY_CACHE` precedent) and reuse + /// the pointer. Rooted by `scan_env_key_cache_mut` (called from + /// `scan_dyn_eval_roots_mut`). + static ENV_KEY_CACHE: RefCell, *const crate::string::StringHeader>> = + RefCell::new(HashMap::new()); +} + +/// Upper bound on distinct interned env keys. Real bodies reuse a tiny +/// identifier vocabulary; this only guards against codegen-heavy / adversarial +/// `new Function` bodies with an unbounded set of distinct local names, each of +/// which would otherwise pin one never-freed longlived allocation for the +/// thread's life. +const ENV_KEY_CACHE_MAX: usize = 4096; + +fn key_string(name: &str) -> *const crate::string::StringHeader { + if let Some(ptr) = ENV_KEY_CACHE.with(|c| c.borrow().get(name).copied()) { + return ptr; + } + // Past the cap, fall back to the pre-cache path: a fresh GC-managed key per + // access (correct — this is exactly the old behavior — just uncached and + // collectable, so the longlived arena can't grow without limit). + if ENV_KEY_CACHE.with(|c| c.borrow().len()) >= ENV_KEY_CACHE_MAX { + return crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) + as *const crate::string::StringHeader; + } + let ptr = crate::string::js_string_from_bytes_longlived(name.as_ptr(), name.len() as u32); + ENV_KEY_CACHE.with(|c| { + c.borrow_mut().insert(name.into(), ptr); + }); + ptr +} + +/// Mark the cached longlived key strings so a collection never treats them as +/// garbage (belt-and-suspenders — longlived blocks are never reset — and it +/// rewrites the slot on the rare evacuating pass, matching `PARSE_KEY_CACHE`). +pub(super) fn scan_env_key_cache_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + ENV_KEY_CACHE.with(|c| { + for ptr in c.borrow_mut().values_mut() { + visitor.visit_tagged_raw_const_ptr_slot(ptr, crate::value::STRING_TAG); + } + }); } fn env_object_ptr(env: f64) -> *mut crate::object::ObjectHeader { @@ -97,17 +147,115 @@ pub(crate) fn define(env: f64, name: &str, value: f64) { env_write(env, name, value); } +/// #6693 surgical prototype (gated by `PERRY_DYN_FAST_SCOPE`): a lean own-field +/// probe for scope objects. Scopes are known-simple — null-proto, +/// `GC_TYPE_OBJECT`, string keys, no accessors — so the general +/// `js_object_get_field_by_name` slow path (proxy/handle/prototype/descriptor +/// vets + key hashing + full keys scan) is pure overhead on the interpreter's +/// hottest operation. This reuses the tested read-plan cache: after the first +/// probe of a `(keys_array, key)` pair every later read is an O(1) index into +/// the field slot (and same-shape sibling scopes share one keys_array via the +/// transition cache, so the cache carries across calls). Like the codegen fast +/// lane it accelerates HITS only and defers anything it can't prove to the +/// authoritative slow path — a capped scan is never mistaken for absence. +enum ScopeProbe { + /// Own binding found; carries its value bits. + Hit(f64), + /// Own binding provably absent (exhaustive scan of a dense keys array on a + /// null-proto object): the caller may walk to the parent with no slow vet. + Absent, + /// Undecided (no keys array / a truncated scan / an overflow slot): the + /// caller must fall back to the authoritative slow read. + Bail, +} + +/// Probe a single scope for `key` without any allocation (so the raw object +/// pointer stays valid for the whole call — no rooting needed inside). +fn scope_probe(env: f64, key: *const crate::string::StringHeader) -> ScopeProbe { + let o = crate::value::js_nanbox_get_pointer(env) as *const crate::object::ObjectHeader; + if o.is_null() { + return ScopeProbe::Bail; + } + unsafe { + let keys = (*o).keys_array; + if keys.is_null() { + return ScopeProbe::Bail; + } + let alloc_limit = std::cmp::max((*o).field_count, 8); + if let Some(idx) = crate::object::prop_plan::read_plan_lookup(keys as usize, key as usize) { + if idx < alloc_limit { + let v = crate::object::js_object_get_field(o, idx); + return ScopeProbe::Hit(f64::from_bits(v.bits())); + } + return ScopeProbe::Bail; + } + let full = crate::array::js_array_length(keys) as usize; + let n = crate::array::keys_array_len_capped_to_capacity(keys); + for i in 0..n as u32 { + let kv = crate::array::js_array_get(keys, i); + if crate::string::js_string_key_matches(kv, key) { + crate::object::prop_plan::read_plan_record(keys as usize, key as usize, i); + if i < alloc_limit { + let v = crate::object::js_object_get_field(o, i); + return ScopeProbe::Hit(f64::from_bits(v.bits())); + } + return ScopeProbe::Bail; + } + } + if n == full { + ScopeProbe::Absent + } else { + ScopeProbe::Bail + } + } +} + /// Read `name`, walking the scope chain. `None` when no scope binds it (the /// caller then falls back to the real `globalThis`). /// /// The cursor lives in a rooted slot: `env_has_own` / `env_parent` allocate /// key strings, and a moving collection triggered by those allocations would /// otherwise leave a raw `f64` cursor stale. +/// +/// #6693 hot path: this runs on EVERY identifier reference. With the fast +/// scope accessor it resolves a hit via the read-plan cache; otherwise it reads +/// the field FIRST and only falls back to `env_has_own` when the read yields +/// `undefined` (a null-proto scope reads a missing key as exactly `undefined`, +/// so the common non-`undefined` binding costs a SINGLE field-op, not the old +/// `has_own` + `read` pair — the field-op, not the key allocation, dominates). pub(crate) fn lookup(env: f64, name: &str) -> Option { + let fast = super::fast_scope_enabled(); let cur_idx = root_push(env); + let key = if fast { key_string(name) } else { std::ptr::null() }; loop { + if fast { + match scope_probe(root_get(cur_idx), key) { + ScopeProbe::Hit(v) => { + roots_truncate(cur_idx); + return Some(v); + } + ScopeProbe::Absent => match env_parent(root_get(cur_idx)) { + Some(p) => { + root_set(cur_idx, p); + continue; + } + None => { + roots_truncate(cur_idx); + return None; + } + }, + ScopeProbe::Bail => {} + } + } + let value = env_read(root_get(cur_idx), name); + if value.to_bits() != crate::value::TAG_UNDEFINED { + roots_truncate(cur_idx); + return Some(value); + } + // Read was `undefined`: either this scope binds it to `undefined`, or + // the key is absent and we must keep walking. Disambiguate with the + // presence check (only reached in the uncommon undefined-value case). if env_has_own(root_get(cur_idx), name) { - let value = env_read(root_get(cur_idx), name); roots_truncate(cur_idx); return Some(value); } @@ -123,9 +271,21 @@ pub(crate) fn lookup(env: f64, name: &str) -> Option { /// Whether any scope in the chain binds `name`. pub(crate) fn is_bound(env: f64, name: &str) -> bool { + let fast = super::fast_scope_enabled(); let cur_idx = root_push(env); + let key = if fast { key_string(name) } else { std::ptr::null() }; loop { - if env_has_own(root_get(cur_idx), name) { + let present = if fast { + match scope_probe(root_get(cur_idx), key) { + ScopeProbe::Hit(_) => Some(true), + ScopeProbe::Absent => Some(false), + ScopeProbe::Bail => None, + } + } else { + None + }; + let present = present.unwrap_or_else(|| env_has_own(root_get(cur_idx), name)); + if present { roots_truncate(cur_idx); return true; } @@ -145,10 +305,22 @@ pub(crate) fn is_bound(env: f64, name: &str) -> bool { /// `value` never declared) — creates the binding on the chain's ROOT scope /// (the Function instance's private "global"). pub(crate) fn assign(env: f64, name: &str, value: f64) { + let fast = super::fast_scope_enabled(); let value_idx = root_push(value); let cur_idx = root_push(env); + let key = if fast { key_string(name) } else { std::ptr::null() }; loop { - if env_has_own(root_get(cur_idx), name) { + let present = if fast { + match scope_probe(root_get(cur_idx), key) { + ScopeProbe::Hit(_) => Some(true), + ScopeProbe::Absent => Some(false), + ScopeProbe::Bail => None, + } + } else { + None + }; + let present = present.unwrap_or_else(|| env_has_own(root_get(cur_idx), name)); + if present { env_write(root_get(cur_idx), name, root_get(value_idx)); roots_truncate(value_idx); return; diff --git a/crates/perry-runtime/src/dyn_eval/interp.rs b/crates/perry-runtime/src/dyn_eval/interp.rs index d4008177ed..23f85e29f6 100644 --- a/crates/perry-runtime/src/dyn_eval/interp.rs +++ b/crates/perry-runtime/src/dyn_eval/interp.rs @@ -291,9 +291,14 @@ pub(crate) fn invoke_interp_fn(fn_id: u32, def_env: f64, this: f64, args: &[f64] let this_idx = root_push(this); let ret_idx = root_push(bridge::undefined()); let def_env_idx = root_push(def_env); + // Root only the arguments a parameter will actually bind. The thunk always + // delivers THUNK_ARITY slots, but a validator declaring one or two params + // (the overwhelming case) needn't pay 16 `root_push`es per call — there is + // no `arguments` object, so surplus args are unobservable (#6693). + let nargs = fun.params.len().min(args.len()).min(THUNK_ARITY); let mut arg_idxs = [0usize; THUNK_ARITY]; - for (i, a) in args.iter().enumerate().take(THUNK_ARITY) { - arg_idxs[i] = root_push(*a); + for (i, slot) in arg_idxs.iter_mut().enumerate().take(nargs) { + *slot = root_push(args[i]); } let call_env = env::env_new(root_get(def_env_idx)); let env_idx = root_push(call_env); @@ -302,7 +307,7 @@ pub(crate) fn invoke_interp_fn(fn_id: u32, def_env: f64, this: f64, args: &[f64] // Parameters. for (i, pat) in fun.params.iter().enumerate() { - let value = if i < THUNK_ARITY { + let value = if i < nargs { root_get(arg_idxs[i]) } else { bridge::undefined() diff --git a/crates/perry-runtime/src/dyn_eval/mod.rs b/crates/perry-runtime/src/dyn_eval/mod.rs index 920fc9d1f8..3629720a2b 100644 --- a/crates/perry-runtime/src/dyn_eval/mod.rs +++ b/crates/perry-runtime/src/dyn_eval/mod.rs @@ -43,9 +43,12 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; +use std::sync::atomic::{AtomicU8, Ordering}; use perry_parser::swc_ecma_ast as ast; +#[cfg(test)] +mod bench; mod bridge; mod env; mod expr; @@ -90,6 +93,69 @@ thread_local! { /// Interpreter call depth (native recursion guard — each interpreted /// frame recurses through the Rust tree-walker). static CALL_DEPTH: Cell = const { Cell::new(0) }; + + /// Assembled-source → prepared function id (#6693). `new Function` with a + /// body identical to one already prepared skips the SWC re-parse + subset + /// scan + hoist prepass entirely — the dominant construction cost — and + /// reuses the registered `InterpFn`. Each `new Function` still returns a + /// FRESH closure over a fresh per-instance root environment, so identity / + /// expando semantics are unchanged; only the parse work is shared. Fastify + /// stacks (ajv / fast-json-stringify / find-my-way) and repeated schema + /// compiles re-`new Function` identical bodies; distinct bodies simply + /// miss (no slower than before). Bounded so a pathological distinct-source + /// stream can't grow it (or `FN_REGISTRY`) without limit. + static SOURCE_FN_CACHE: RefCell> = RefCell::new(HashMap::new()); + + /// Aggregate byte size of the source strings currently held in + /// `SOURCE_FN_CACHE`. The entry-count cap alone doesn't bound memory — + /// `new Function` bodies are script-controlled and can be large (real + /// TypeBox validators reach ~58 KB), so 4096 large distinct bodies would + /// retain hundreds of MB. This tracks the total so we can cap by size too. + static SOURCE_FN_CACHE_BYTES: Cell = const { Cell::new(0) }; +} + +/// Upper bound on distinct cached sources. Codegen sites are few; this only +/// guards against an adversarial stream of unique bodies. On overflow new +/// distinct sources still work — they just aren't memoized. +const SOURCE_FN_CACHE_MAX: usize = 4096; + +/// Aggregate byte cap on cached source strings (defense-in-depth alongside the +/// entry-count cap). Past this, new distinct sources still run — just uncached. +const SOURCE_FN_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; + +// ── #6693 runtime A/B toggles ─────────────────────────────────────────────── +// Read once per process, then a relaxed atomic load on the hot path. 0 = +// unresolved, 1 = on, 2 = off. Let the SAME compiled binary A/B each win on +// the real bundle without recompiling: `PERRY_DYN_NO_PARSE_CACHE=1` reverts to +// re-parse-every-call (the pre-#6693 parse behavior), and `PERRY_DYN_FAST_SCOPE=1` +// enables the lean plain-scope env accessor (the prototype surgical fix). +static PARSE_CACHE_OFF: AtomicU8 = AtomicU8::new(0); +static FAST_SCOPE_ON: AtomicU8 = AtomicU8::new(0); + +fn env_toggle(slot: &AtomicU8, var: &str) -> bool { + match slot.load(Ordering::Relaxed) { + 1 => true, + 2 => false, + _ => { + let on = std::env::var_os(var) + .map(|v| v != "0" && !v.is_empty()) + .unwrap_or(false); + slot.store(if on { 1 } else { 2 }, Ordering::Relaxed); + on + } + } +} + +/// Whether the source→`InterpFn` parse cache is active (default on; disabled by +/// `PERRY_DYN_NO_PARSE_CACHE=1` to A/B its effect on the real grind). +fn parse_cache_enabled() -> bool { + !env_toggle(&PARSE_CACHE_OFF, "PERRY_DYN_NO_PARSE_CACHE") +} + +/// Whether the lean plain-scope env accessor is active (default off; enabled by +/// `PERRY_DYN_FAST_SCOPE=1`). #6693 prototype. +pub(crate) fn fast_scope_enabled() -> bool { + env_toggle(&FAST_SCOPE_ON, "PERRY_DYN_FAST_SCOPE") } /// Cap on interpreter recursion. Each interpreted call consumes native stack @@ -195,6 +261,8 @@ pub fn scan_dyn_eval_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) visitor.visit_nanbox_u64_slot(slot); } }); + env::scan_env_key_cache_mut(visitor); + bridge::scan_member_key_cache_mut(visitor); } // ── entry point ──────────────────────────────────────────────────────────── @@ -216,12 +284,53 @@ pub fn dyn_function_from_strings(args: &[String]) -> f64 { // expression so top-level `return` (which every ajv/fjs/fmw body uses) // parses, and the parameter text is validated by the same parse. let source = format!("(function anonymous({params}\n) {{\n{body}\n}})"); + // Parse cache: an identical assembled source reuses the already-prepared + // `InterpFn` (same `FN_REGISTRY` id, same stable AST-node addresses that + // the nested-function cache keys on) — skipping SWC parse + subset scan + + // hoist prepass. A cache hit still builds a fresh root env + closure below. + let fn_id = if !parse_cache_enabled() { + prepare_source(&source) + } else { + match SOURCE_FN_CACHE.with(|c| c.borrow().get(&source).copied()) { + Some(id) => id, + None => { + let id = prepare_source(&source); + SOURCE_FN_CACHE.with(|c| { + let mut c = c.borrow_mut(); + let bytes = SOURCE_FN_CACHE_BYTES.with(|b| b.get()); + if c.len() < SOURCE_FN_CACHE_MAX + && bytes + source.len() <= SOURCE_FN_CACHE_MAX_BYTES + { + SOURCE_FN_CACHE_BYTES.with(|b| b.set(bytes + source.len())); + c.insert(source, id); + } + }); + id + } + } + }; + // The instance's root environment: undeclared-assignment target (sloppy + // implicit "globals" scoped to this Function instance) and the parent of + // every call scope. + let root_env = env::env_new_root(); + let root_idx = root_push(root_env); + let closure = interp::alloc_interp_closure(fn_id, root_get(root_idx), None); + roots_truncate(root_idx); + closure +} + +/// Parse an assembled `(function anonymous(…){…})` source, reject +/// out-of-subset constructs eagerly, run the hoist prepass, and register the +/// resulting `InterpFn`. Returns its `FN_REGISTRY` id. Throws SyntaxError on a +/// parse failure and TypeError on an unsupported construct — the same +/// diagnostics as before the parse cache existed; only a cache MISS runs this. +fn prepare_source(source: &str) -> u32 { // `.cjs` pins script (sloppy, non-module) parsing: generated bodies rely // on sloppy semantics (find-my-way assigns the undeclared `value`), and // module auto-detection must not kick in on `import(`-looking substrings. let mut cache = perry_diagnostics_cache(); let parsed = - match perry_parser::parse_typescript_with_cache(&source, "perry-dyn-fn.cjs", &mut cache) { + match perry_parser::parse_typescript_with_cache(source, "perry-dyn-fn.cjs", &mut cache) { Ok(p) => p, Err(e) => bridge::throw_syntax_error(&format!( "invalid or unsupported source in runtime `new Function` body: {e}" @@ -242,15 +351,7 @@ pub fn dyn_function_from_strings(args: &[String]) -> f64 { func.params.into_iter().map(|p| p.pat).collect(), InterpBody::Block(func.body.map(|b| b.stmts).unwrap_or_default()), ); - let fn_id = register_fn(interp_fn); - // The instance's root environment: undeclared-assignment target (sloppy - // implicit "globals" scoped to this Function instance) and the parent of - // every call scope. - let root_env = env::env_new_root(); - let root_idx = root_push(root_env); - let closure = interp::alloc_interp_closure(fn_id, root_get(root_idx), None); - roots_truncate(root_idx); - closure + register_fn(interp_fn) } fn perry_diagnostics_cache() -> perry_diagnostics::SourceCache { diff --git a/crates/perry-runtime/src/dyn_eval/tests.rs b/crates/perry-runtime/src/dyn_eval/tests.rs index 3162bf547b..955e377fe3 100644 --- a/crates/perry-runtime/src/dyn_eval/tests.rs +++ b/crates/perry-runtime/src/dyn_eval/tests.rs @@ -724,3 +724,70 @@ fn probe_all_readers_of_null_closure_expando() { ); } } + +// ── #6693 perf-optimization parity ───────────────────────────────────────── +// The parse cache (source → prepared `InterpFn`) and the get-first `env::lookup` +// must be pure speedups: identical source cached vs fresh yields identical +// behavior, distinct sources never collide, each `new Function` still gets its +// own instance environment, and a binding whose value is exactly `undefined` +// is still resolved (not skipped past as if absent). + +#[test] +fn parse_cache_same_source_identical_result() { + // The 2nd construction of an identical source is a parse-cache HIT (reuses + // the registered `InterpFn`); both closures must compute the same thing. + let src = "return a * 3 + b - 1"; + let f1 = dyn_fn(&["a", "b", src]); + let f2 = dyn_fn(&["a", "b", src]); + assert_eq!(as_num(call(f1, &[num(4.0), num(5.0)])), 16.0); + assert_eq!(as_num(call(f2, &[num(4.0), num(5.0)])), 16.0); + // And a fresh, larger identical-source run still matches (cache stable + // across many hits). + for _ in 0..50 { + let f = dyn_fn(&["a", "b", src]); + assert_eq!(as_num(call(f, &[num(2.0), num(7.0)])), 12.0); + } +} + +#[test] +fn parse_cache_distinct_sources_do_not_collide() { + let f_add = dyn_fn(&["a", "b", "return a + b"]); + let f_mul = dyn_fn(&["a", "b", "return a * b"]); + let f_sub = dyn_fn(&["a", "b", "return a - b"]); + assert_eq!(as_num(call(f_add, &[num(3.0), num(4.0)])), 7.0); + assert_eq!(as_num(call(f_mul, &[num(3.0), num(4.0)])), 12.0); + assert_eq!(as_num(call(f_sub, &[num(3.0), num(4.0)])), -1.0); + // Interleaved re-construction of each (mix of cache hits) stays correct. + let f_add2 = dyn_fn(&["a", "b", "return a + b"]); + assert_eq!(as_num(call(f_add2, &[num(10.0), num(1.0)])), 11.0); + assert_eq!(as_num(call(f_mul, &[num(6.0), num(6.0)])), 36.0); +} + +#[test] +fn parse_cache_hit_keeps_independent_instance_environments() { + // Sloppy assignment to an undeclared name lands on THIS `new Function` + // instance's private root env and persists across its own calls. Two + // instances of the SAME source (2nd is a cache hit — same `InterpFn`) must + // NOT share that env; the cache reuses the parsed body, never the scope. + let src = "count = (typeof count === 'undefined' ? 0 : count) + 1; return count"; + let f1 = dyn_fn(&[src]); + let f2 = dyn_fn(&[src]); // parse-cache hit + assert_eq!(as_num(call(f1, &[])), 1.0); + assert_eq!(as_num(call(f1, &[])), 2.0); // f1's own root env accumulates + assert_eq!(as_num(call(f2, &[])), 1.0); // f2 is independent → starts fresh + assert_eq!(as_num(call(f1, &[])), 3.0); // f1 unaffected by f2 +} + +#[test] +fn get_first_lookup_resolves_declared_undefined_binding() { + // The get-first `env::lookup` reads the field before checking presence. + // An inner block binding of exactly `undefined` must still SHADOW the + // outer binding — the read must resolve to the inner `undefined`, not walk + // past it (the `has_own` disambiguation branch). + let f = dyn_fn(&["let x = 1; { let x; return x === undefined ? 'inner' : 'outer'; }"]); + assert_eq!(as_str(call(f, &[])), "inner"); + // A param bound to an explicit `undefined` argument is likewise found as + // its own binding, not resolved to a same-named outer/global. + let g = dyn_fn(&["p", "return typeof p"]); + assert_eq!(as_str(call(g, &[bridge::undefined()])), "undefined"); +}