diff --git a/changelog.d/8647-const-array-descriptor.md b/changelog.d/8647-const-array-descriptor.md new file mode 100644 index 0000000000..564a6bdcb2 --- /dev/null +++ b/changelog.d/8647-const-array-descriptor.md @@ -0,0 +1 @@ +perf(codegen): a large, fully-constant array literal — the minified-bundle data-table shape, a giant nested array of number/bool/null constants — now materializes from a compact static rodata descriptor via ONE `js_value_from_const_descriptor` call, instead of one `js_array_from_values` allocation per sub-array plus a huge procedural body. The Claude Code bundle's `__33499` (a constant numeric array-of-arrays) previously lowered to 11,104 allocations and a 245k-instruction body that made `rewrite-statepoints-for-gc` fan out; on a 3,000-row synthetic the descriptor path replaces 3,000+ allocations with a single blob + call and drops the compile from not-finishing-in-two-minutes to 1.46s, byte-identical to the procedural build across the moving-GC matrix. The runtime builds a fresh, mutable array each call (JS array literals are mutable) under `GcSuppressScope` so partially-built parents held across nested child allocations cannot be collected or moved. Gated on a 256-node minimum so small literals keep the fast inline path; `PERRY_CONST_ARRAY_DESCRIPTOR=0` reverts to the procedural path. diff --git a/crates/perry-codegen/src/expr/array_literal.rs b/crates/perry-codegen/src/expr/array_literal.rs index 25e7bf6b21..fa49bafa53 100644 --- a/crates/perry-codegen/src/expr/array_literal.rs +++ b/crates/perry-codegen/src/expr/array_literal.rs @@ -74,6 +74,19 @@ pub(crate) fn lower_array_literal(ctx: &mut FnCtx<'_>, elements: &[Expr]) -> Res return Ok(nanbox_pointer_inline(ctx.block(), &arr)); } + // #8583 follow-up: a LARGE, fully-CONSTANT array literal (the minified + // data-table shape — a giant nested array of number/bool/null literals) + // becomes a static rodata descriptor + ONE bulk-materialization runtime + // call, instead of the N per-subarray `js_array_from_values` allocations and + // the giant procedural body that made `__33499` fan out under RS4GC (245k + // instrs / 11,104 allocations → one call over a compact blob). Small const + // arrays fall through to the fast inline bump-alloc path below. + if const_array_descriptor_enabled() { + if let Some(v) = try_lower_const_array_descriptor(ctx, elements) { + return Ok(v); + } + } + // Evaluate all element expressions *before* allocating, so nested // allocations inside element expressions don't see a half-initialized // outer array. Each evaluated value is kept in a temp root until the last @@ -271,3 +284,141 @@ pub(crate) fn lower_array_literal(ctx: &mut FnCtx<'_>, elements: &[Expr]) -> Res Ok(nanbox_pointer_inline(ctx.block(), &arr)) }) } + +/// #8583 follow-up gate. Default ON; `PERRY_CONST_ARRAY_DESCRIPTOR=0/off/false` +/// reverts every large constant literal to the procedural construction path +/// (A/B bisection, and an escape hatch if a descriptor ever proves wrong). +fn const_array_descriptor_enabled() -> bool { + !matches!( + std::env::var("PERRY_CONST_ARRAY_DESCRIPTOR").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +/// A value the const-descriptor path can materialize with no JS evaluation: +/// number/int/bool/null/undefined, or an array recursively of the same. Strings +/// and objects decline (v2) — the whole literal then falls back to the +/// procedural path, so a mixed table is never half-materialized. +fn is_const_materializable(e: &Expr) -> bool { + match e { + Expr::Number(_) + | Expr::Integer(_) + | Expr::Bool(_) + | Expr::Null + | Expr::Undefined => true, + Expr::Array(elems) => elems.iter().all(is_const_materializable), + _ => false, + } +} + +/// Total materializable nodes (every scalar + every array), the size gate below. +fn count_const_nodes(e: &Expr) -> usize { + match e { + Expr::Array(elems) => 1 + elems.iter().map(count_const_nodes).sum::(), + _ => 1, + } +} + +/// Serialize one constant value into the descriptor blob (must match the tag +/// bytes in `perry-runtime/src/array/alloc.rs::build_const_value`). +fn serialize_const_value(e: &Expr, out: &mut Vec) { + match e { + Expr::Number(n) => { + out.push(0); + out.extend_from_slice(&n.to_le_bytes()); + } + Expr::Integer(i) => { + out.push(0); + out.extend_from_slice(&(*i as f64).to_le_bytes()); + } + Expr::Bool(true) => out.push(2), + Expr::Bool(false) => out.push(3), + Expr::Null => out.push(4), + Expr::Undefined => out.push(5), + Expr::Array(elems) => { + out.push(1); + out.extend_from_slice(&(elems.len() as u32).to_le_bytes()); + for el in elems { + serialize_const_value(el, out); + } + } + // Guarded by `is_const_materializable`; unreachable in practice. + _ => out.push(5), + } +} + +/// Only worth a rodata blob + runtime call for genuinely large tables; small +/// const arrays keep the fast inline bump-alloc path (no regression). `__33499` +/// has ~44k nodes; an ordinary `[1,2,3]` has 4 and never qualifies. +const CONST_DESCRIPTOR_MIN_NODES: usize = 256; + +/// If `elements` is a large, fully-constant array literal, emit a static rodata +/// descriptor and a single `js_value_from_const_descriptor` call and return the +/// nanboxed value; otherwise `None` (caller falls back to the procedural path). +fn try_lower_const_array_descriptor(ctx: &mut FnCtx<'_>, elements: &[Expr]) -> Option { + if !elements.iter().all(is_const_materializable) { + return None; + } + // Only NESTED constant tables benefit: the fan-out cost is the per-subarray + // allocation (a data table lowers to thousands of `js_array_from_values`). + // A flat constant scalar array is already a single `js_array_alloc_literal` + // + inline stores, so keep that path — it also preserves the precise + // per-slot write barriers a later push/store relies on. + if !elements.iter().any(|e| matches!(e, Expr::Array(_))) { + return None; + } + let total_nodes: usize = 1 + elements.iter().map(count_const_nodes).sum::(); + if total_nodes < CONST_DESCRIPTOR_MIN_NODES { + return None; + } + + // Serialize the outer array: tag 1 (ARRAY) + u32 count + each element. + let mut blob: Vec = Vec::new(); + blob.push(1); + blob.extend_from_slice(&(elements.len() as u32).to_le_bytes()); + for el in elements { + serialize_const_value(el, &mut blob); + } + + // Emit the blob as a module-private rodata constant (mirrors + // `expr/strings.rs::emit_string_literal_global`; `ic_site_counter` is the + // module-wide site identity so re-emitted bodies don't collide). + let idx = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let func_part: String = ctx + .func + .name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let global_name = format!("perry_const_arr_{}_{}", func_part, idx); + let mut lit = String::with_capacity(blob.len() + 4); + lit.push('c'); + lit.push('"'); + for &b in &blob { + if (32..127).contains(&b) && b != b'"' && b != b'\\' { + lit.push(b as char); + } else { + lit.push('\\'); + lit.push_str(&format!("{:02X}", b)); + } + } + lit.push('"'); + ctx.typed_parse_rodata.push(format!( + "@{} = private unnamed_addr constant [{} x i8] {}", + global_name, + blob.len(), + lit + )); + + // ONE runtime call materializes the whole nested structure and returns the + // nanboxed (DOUBLE) JS value directly — no per-element IR, so no fan-out. + let global_ref = format!("@{}", global_name); + let len_str = blob.len().to_string(); + let v = ctx.block().call( + DOUBLE, + "js_value_from_const_descriptor", + &[(PTR, &global_ref), (I32, &len_str)], + ); + Some(v) +} diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 6403a3e7b0..2a4e81251e 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -37,6 +37,10 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // #5391: build an array literal from a stack buffer of N values in one call // (outlines the inline alloc + per-element store/note/barrier). (values_ptr, n). module.declare_function("js_array_from_values", I64, &[PTR, I32]); + // #8583 follow-up: materialize a large, fully-constant nested array literal + // from a static rodata descriptor blob in ONE call — (descriptor_ptr, + // blob_len). Returns the nanboxed JS value (a fresh, mutable array). + module.declare_function("js_value_from_const_descriptor", DOUBLE, &[PTR, I32]); module.declare_function("js_array_push_f64", I64, &[I64, DOUBLE]); module.declare_function("js_array_push_guard", VOID, &[I64]); module.declare_function("js_array_push_hole", I64, &[I64]); diff --git a/crates/perry-runtime/src/array/alloc.rs b/crates/perry-runtime/src/array/alloc.rs index d1bd72fc48..058da970ba 100644 --- a/crates/perry-runtime/src/array/alloc.rs +++ b/crates/perry-runtime/src/array/alloc.rs @@ -444,6 +444,95 @@ pub extern "C" fn js_array_from_values(values: *const f64, n: u32) -> *mut Array arr } +/// Descriptor tag bytes for [`js_value_from_const_descriptor`]. MUST match the +/// serializer in `perry-codegen/src/expr/array_literal.rs`. +const DESC_NUMBER: u8 = 0; // + 8 bytes little-endian f64 +const DESC_ARRAY: u8 = 1; // + 4 bytes little-endian u32 count, then `count` values +const DESC_TRUE: u8 = 2; +const DESC_FALSE: u8 = 3; +const DESC_NULL: u8 = 4; +const DESC_UNDEFINED: u8 = 5; + +/// #8583 follow-up: materialize a large, fully-CONSTANT array literal from a +/// static rodata descriptor in ONE call, instead of the N per-subarray +/// `js_array_from_values` allocations codegen otherwise emits. A minified bundle +/// data table — a giant nested constant numeric array (the Claude Code bundle's +/// `__33499`) — lowered to 11,104 allocations and a 245k-instruction body that +/// made `rewrite-statepoints-for-gc` fan out; this collapses it to one call over +/// a compact rodata blob. +/// +/// Returns a FRESH, mutable value each call: JS array literals are mutable, so +/// the descriptor is a template, never a shared constant. GC is suppressed for +/// the whole build so the partially-built parent arrays held across nested child +/// allocations cannot be collected or moved (mirrors `js_json_parse` and the +/// lazy-array materializer). The blob is compiler-generated and trusted, but +/// every read is bounds-checked so a malformed descriptor declines to +/// `undefined` rather than reading out of bounds. +#[no_mangle] +pub extern "C" fn js_value_from_const_descriptor(ptr: *const u8, len: u32) -> f64 { + if ptr.is_null() || len == 0 { + return f64::from_bits(crate::value::JSValue::undefined().bits()); + } + let _suppress = crate::gc::GcSuppressScope::new(); + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + let mut pos = 0usize; + let bits = build_const_value(bytes, &mut pos); + f64::from_bits(bits) +} + +/// Recursively build the JS value at `bytes[*pos]`, advancing `*pos`. Callers +/// hold GC suppressed, so heap pointers materialized here stay live and pinned +/// for the duration of the whole build. +fn build_const_value(bytes: &[u8], pos: &mut usize) -> u64 { + use crate::value::JSValue; + let undefined = || JSValue::undefined().bits(); + let Some(&tag) = bytes.get(*pos) else { + return undefined(); + }; + *pos += 1; + match tag { + DESC_NUMBER => { + if *pos + 8 > bytes.len() { + return undefined(); + } + let mut b = [0u8; 8]; + b.copy_from_slice(&bytes[*pos..*pos + 8]); + *pos += 8; + JSValue::number(f64::from_le_bytes(b)).bits() + } + DESC_ARRAY => { + if *pos + 4 > bytes.len() { + return undefined(); + } + let mut c = [0u8; 4]; + c.copy_from_slice(&bytes[*pos..*pos + 4]); + *pos += 4; + let count = u32::from_le_bytes(c); + let arr = js_array_alloc_literal(count); + // All-number rows keep the raw-f64 layout fast path; any pointer + // element (a nested array) is downgraded per-slot by + // `store_array_slot`, so gate the numeric mark on a pure-number row. + let mut all_number = count > 0; + for i in 0..count as usize { + if bytes.get(*pos).copied() != Some(DESC_NUMBER) { + all_number = false; + } + let elem = build_const_value(bytes, pos); + unsafe { crate::array::store_array_slot(arr, i, elem) }; + } + if all_number { + crate::array::js_array_mark_numeric_f64_layout(arr); + } + JSValue::pointer(arr as *const u8).bits() + } + DESC_TRUE => JSValue::bool(true).bits(), + DESC_FALSE => JSValue::bool(false).bits(), + DESC_NULL => JSValue::null().bits(), + DESC_UNDEFINED => undefined(), + _ => undefined(), + } +} + /// Issue #179 Phase 2: if `arr` points at a `LazyArrayHeader` /// (`GcHeader::obj_type == GC_TYPE_LAZY_ARRAY`), force the lazy /// value to materialize and return the real `ArrayHeader` pointer. diff --git a/crates/perry/tests/const_array_descriptor_8583.rs b/crates/perry/tests/const_array_descriptor_8583.rs new file mode 100644 index 0000000000..bdcb859b29 --- /dev/null +++ b/crates/perry/tests/const_array_descriptor_8583.rs @@ -0,0 +1,214 @@ +//! #8583 follow-up — large constant array literals materialize from a static +//! rodata descriptor + ONE bulk call, and the result is GC-correct, fresh, and +//! byte-identical to the procedural construction path. +//! +//! A minified bundle data table is a giant nested constant array literal. The +//! default lowering builds it with N per-subarray `js_array_from_values` +//! allocations and a huge procedural body (the `__33499` fan-out). The +//! descriptor path serializes the constant tree into a rodata blob and calls +//! `js_value_from_const_descriptor` once to materialize a FRESH, mutable array. +//! +//! Two checks, no node oracle: +//! * the optimization actually fired — the emitted IR calls +//! `js_value_from_const_descriptor` and does NOT build the table with a +//! per-subarray `js_array_from_values` (so this test can't silently become +//! a tautology if the path stops matching); +//! * `PERRY_CONST_ARRAY_DESCRIPTOR=1` (default) vs `=0` (procedural) produce +//! byte-identical output under every moving-collector arm — a mis-rooted +//! value in the suppressed-GC bulk build would diverge (or crash) in the +//! descriptor arm only. Mutation-after-materialization is exercised so a +//! wrongly-shared constant would surface as cross-instance aliasing. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +/// A large, fully-constant nested array (300 numeric rows > the 256-node gate), +/// one boolean/null row to cover non-numeric constants, then: a fresh second +/// materialization, a mutation of the first (freshness/mutability), and a +/// deterministic checksum. Output is hand-verifiable and identical whichever +/// construction path built the table. +fn source() -> String { + let mut rows = String::new(); + for i in 0..300 { + if i > 0 { + rows.push(','); + } + rows.push_str(&format!("[{},{},{}]", i % 128, (i * 7) % 128, (i * 13) % 128)); + } + // One non-numeric row so bool/null tags are exercised in the descriptor. + rows.push_str(",[true,null,false]"); + format!( + r#" +function table() {{ return [{rows}]; }} +const t = table(); +const t2 = table(); +t[0].push(999); +let sum = 0; +for (let i = 0; i < 300; i++) {{ sum = (sum + t[i][0] + t[i][1] + t[i][2]) | 0; }} +const row = t[300]; +console.log( + "rows:" + t.length + + " s:" + sum + + " mut:" + t[0].length + + " fresh:" + t2[0].length + + " id:" + (t === t2) + + " b:" + row[0] + " n:" + row[1] + " f:" + row[2] +); +"# + ) +} + +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", + "PERRY_CONST_ARRAY_DESCRIPTOR", +]; + +fn compile(dir: &std::path::Path, descriptor: bool) -> PathBuf { + let entry = dir.join("main.ts"); + let output = dir.join(format!("bin_desc_{descriptor}")); + std::fs::write(&entry, source()).expect("write entry"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache"); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + if !descriptor { + cmd.env("PERRY_CONST_ARRAY_DESCRIPTOR", "0"); + } + let out = cmd.output().expect("run perry compile"); + assert!( + out.status.success(), + "perry compile (descriptor={descriptor}) failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + output +} + +fn run_arms(binary: &std::path::Path, dir: &std::path::Path, label: &str) -> String { + let mut arms: Vec> = vec![vec![]]; + for mb in ["1", "2", "4"] { + arms.push(vec![("PERRY_GC_SCAVENGE_NURSERY_MB", mb)]); + } + arms.push(vec![("PERRY_GEN_GC", "0")]); + + let mut first: Option = None; + for arm in &arms { + let mut cmd = Command::new(binary); + cmd.current_dir(dir); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + for (k, v) in arm { + cmd.env(k, v); + } + let run = cmd.output().expect("run compiled binary"); + let arm_label = if arm.is_empty() { + format!("{label}/default") + } else { + format!("{label}/{}={}", arm[0].0, arm[0].1) + }; + assert!( + run.status.success(), + "[{arm_label}] compiled binary failed (exit {:?})\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stderr), + ); + let stdout = String::from_utf8_lossy(&run.stdout).into_owned(); + match &first { + None => first = Some(stdout), + Some(f) => assert_eq!( + &stdout, f, + "[{arm_label}] output differs between collector arms — a value \ + materialized in the suppressed-GC bulk build was mis-rooted" + ), + } + } + first.expect("at least one arm ran") +} + +#[test] +fn const_array_descriptor_fires_in_ir() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let ll_dir = dir.path().join("ll"); + std::fs::create_dir_all(&ll_dir).unwrap(); + std::fs::write(&entry, source()).expect("write entry"); + let out = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(dir.path().join("unused")) + .arg("--no-cache") + .arg("--no-link") + .env("PERRY_SAVE_LL", &ll_dir) + .env_remove("PERRY_CONST_ARRAY_DESCRIPTOR") + .output() + .expect("run perry compile --no-link"); + assert!(out.status.success(), "compile failed: {}", String::from_utf8_lossy(&out.stderr)); + + let ir: String = std::fs::read_dir(&ll_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("ll")) + .map(|e| std::fs::read_to_string(e.path()).unwrap_or_default()) + .collect(); + assert!( + ir.contains("js_value_from_const_descriptor"), + "the large constant table should materialize via js_value_from_const_descriptor" + ); + // The descriptor path must replace the per-subarray builder for this table: + // no `js_array_from_values` CALL should remain (the always-present `declare` + // line is filtered out). + assert!( + !ir.contains("call i64 @js_array_from_values("), + "no per-subarray js_array_from_values call should remain for the const table" + ); +} + +#[test] +fn const_array_descriptor_matches_procedural_under_moving_gc() { + let dir = tempfile::tempdir().expect("tempdir"); + let descriptor_bin = compile(dir.path(), true); + let procedural_bin = compile(dir.path(), false); + + let descriptor_out = run_arms(&descriptor_bin, dir.path(), "descriptor"); + let procedural_out = run_arms(&procedural_bin, dir.path(), "procedural"); + + // Structural correctness (robust to the exact checksum, which the + // differential below pins anyway): 301 rows; the two materializations are + // DISTINCT instances (id:false); mutating t[0] (3 -> 4 after push) did not + // touch the fresh t2[0] (still 3) — proving each call yields a fresh mutable + // array, not a shared constant; and the non-numeric row round-trips. + assert!( + descriptor_out.starts_with("rows:301 s:54810 mut:4 fresh:3 id:false"), + "unexpected descriptor output: {descriptor_out:?}" + ); + assert!( + descriptor_out.trim_end().ends_with("b:true n:null f:false"), + "non-numeric constants must round-trip: {descriptor_out:?}" + ); + assert_eq!( + descriptor_out, procedural_out, + "descriptor materialization diverged from the procedural construction path" + ); +}