Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/8646-computed-property-reflection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Completed computed property name reflection so a computed key observes the same
reflection surface as a literal one.
1 change: 1 addition & 0 deletions changelog.d/8647-const-array-descriptor.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions changelog.d/8650-test262-builtins-misc-tail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Closed the remaining test262 built-ins "misc tail" semantics gaps across the
class-registry construct path, descriptors, prototype chain and proxy
put-value.

Also lowers the raw-handle ratchet 925 -> 923: the converted sites use the
#7341 handle shapes rather than re-baselining, so the debt figure drops with
the change that pays it down.
12 changes: 12 additions & 0 deletions changelog.d/8651-array-growth-generation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Fixed retained array-growth forwarding stubs pointing into resetting nursery
space. When an old array outgrew its backing storage, `js_array_grow` could
allocate the replacement in the copying nursery and leave the permanent old
stub pointing at it. Minor GC does not trace that forwarding payload as a
normal array slot, so a later nursery reset could recycle the target while
stale aliases still followed the old forwarding word.

Array growth now keeps the replacement out of the nursery whenever the source
is old or otherwise non-moving. A young source uses nursery space only through
the no-collection allocator; if growth could collect and promote the source,
the replacement is born old instead. This fixes the intermittent ECS failure
reported as `Cannot assign to read only property 'length' of object`.
1 change: 1 addition & 0 deletions changelog.d/8652-diverged-block-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix(codegen): block-creating lowerings (`lower_index_set_fast`, `emit_persistent_shadow_root_barrier`) now emit nothing once the current block is terminated. When a sub-expression provably diverges — a throwing operand (a captured TDZ access / const-reassignment) emits a throw + `unreachable` — the block is terminated and its trailing setup registers are silently dropped, but the guarded fast path / root barrier still created fresh blocks referencing those dropped registers, which the dialect builder rejected as "register %rN used but never defined". Guarding on `ctx.block().is_terminated()` fixes the (unreachable) dead code. Also adds an env-gated `PERRY_DIALECT_DUMP=<dir>` diagnostic that, on a dialect construction failure, names the offending function and dumps its full IR (the failing unit never parses, so `PERRY_SAVE_LL` cannot capture it). Together with #8633 this lets the Claude Code 2.1.112 bundle codegen all 84 units cleanly (previously failed at unit 25).
13 changes: 13 additions & 0 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,7 @@ pub(super) fn init_static_fields_early(
let mut cur: Option<String> = c.extends_name.clone();
let mut extends_error = false;
let mut extends_data_view = false;
let mut extends_typed_array = false;
let mut depth = 0usize;
while let Some(name) = cur {
if matches!(
Expand All @@ -1201,6 +1202,10 @@ pub(super) fn init_static_fields_early(
extends_data_view = true;
break;
}
if crate::type_analysis::is_typed_array_class(&name) {
extends_typed_array = true;
break;
}
// Walk user-defined ancestor chain.
if let Some(parent) = ctx.classes.get(&name) {
cur = parent.extends_name.clone();
Expand Down Expand Up @@ -1230,6 +1235,14 @@ pub(super) fn init_static_fields_early(
);
}
}
if extends_typed_array {
if let Some(&cid) = ctx.class_ids.get(&c.name) {
ctx.block().call_void(
"js_register_class_extends_typed_array",
&[(crate::types::I32, &cid.to_string())],
);
}
}
}
// Well-known symbol class hooks: HIR lifts `static [Symbol.hasInstance]`
// and `get [Symbol.toStringTag]` to top-level functions with the
Expand Down
147 changes: 147 additions & 0 deletions crates/perry-codegen/src/expr/array_literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -271,3 +284,137 @@ 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::<usize>(),
_ => 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<u8>) {
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<String> {
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::<usize>();
if total_nodes < CONST_DESCRIPTOR_MIN_NODES {
return None;
}

// Serialize the outer array: tag 1 (ARRAY) + u32 count + each element.
let mut blob: Vec<u8> = 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)
}
13 changes: 13 additions & 0 deletions crates/perry-codegen/src/expr/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ pub(crate) fn lower_index_set_fast(
value_is_canonical_raw_f64: bool,
feedback_site_id: &str,
) -> Result<()> {
// #8583-followup: if evaluating an operand diverged — a throwing
// sub-expression (e.g. a TDZ access on a captured `let`) emitted a
// `js_throw_error_with_code` + `unreachable` — the current block is
// terminated. `LlBlock` silently drops any instruction emitted after a
// terminator, so the element setup below (`arr_bits`/`arr_handle`/`idx_i32`)
// is dropped, but the guarded fast path still creates fresh blocks that
// reference those dropped registers, which the dialect builder rejects as
// "register %rN used but never defined". The index-set is unreachable on
// this path, so emit nothing.
if ctx.block().is_terminated() {
return Ok(());
}

// Capture the local slot for the realloc path.
let slot = ctx
.locals
Expand Down
10 changes: 10 additions & 0 deletions crates/perry-codegen/src/expr/shadow_slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,16 @@ pub(crate) fn emit_shadow_slot_bind_ptr(ctx: &mut FnCtx<'_>, slot_idx: u32, slot
/// `monotonic`, matching the runtime's Rust `Relaxed` readers: the counter is
/// only a gate and does not publish accompanying memory.
pub(crate) fn emit_persistent_shadow_root_barrier(ctx: &mut FnCtx<'_>, value_bits: &str) {
// #8583-followup: if computing the value diverged (a throwing sub-expression
// — e.g. a TDZ access on a captured `let` — emitted `unreachable`), the
// current block is terminated. `LlBlock` drops instructions emitted after a
// terminator, so `value_bits`' defining instruction was silently discarded;
// the barrier block created below would then reference an undefined register
// ("register %rN used but never defined"). The root store is unreachable on
// this path, so emit no barrier.
if ctx.block().is_terminated() {
return;
}
let active =
ctx.block()
.load_atomic_monotonic(I32, "@PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT", 4);
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/expr/unary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
if numeric {
Ok(v)
} else {
Ok(blk.call(DOUBLE, "js_number_coerce", &[(DOUBLE, &v)]))
Ok(blk.call(DOUBLE, "js_dynamic_pos", &[(DOUBLE, &v)]))
}
}
UnaryOp::Not => {
Expand Down
60 changes: 53 additions & 7 deletions crates/perry-codegen/src/native_emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,20 +255,66 @@ fn stream_frozen_functions<'ctx>(
.map_err(|e| anyhow!("native IR construction failed in @{}: {e:#}", f.name))?;
for item in &f.items {
use crate::function::FinalItem as FI;
match item {
FrozenItem::Label(s) => stream.item(&FI::Label(s))?,
FrozenItem::Blank => stream.item(&FI::Blank)?,
FrozenItem::Text(s) => stream.item(&FI::Text(s))?,
FrozenItem::Inst(i) => stream.item(&FI::Inst(i))?,
}
let res = match item {
FrozenItem::Label(s) => stream.item(&FI::Label(s)),
FrozenItem::Blank => stream.item(&FI::Blank),
FrozenItem::Text(s) => stream.item(&FI::Text(s)),
FrozenItem::Inst(i) => stream.item(&FI::Inst(i)),
};
res.map_err(|e| dump_dialect_failure(f, e))?;
}
let (t, r) = stream.finish()?;
let (t, r) = stream.finish().map_err(|e| dump_dialect_failure(f, e))?;
typed += t;
raw += r;
}
Ok((typed, raw))
}

/// Diagnostic for a dialect construction failure (e.g. "register %rN was used
/// but never defined"): name the offending function and, when
/// `PERRY_DIALECT_DUMP=<dir>` is set, write the function's full constructed IR
/// text (typed insts rendered via `render_into`) to `<dir>/<name>.ll` so the
/// malformed use site is visible. The failing unit never parses, so the normal
/// `PERRY_SAVE_LL` post-parse dump cannot capture it.
fn dump_dialect_failure(f: &FrozenFunction, e: anyhow::Error) -> anyhow::Error {
if let Ok(dir) = std::env::var("PERRY_DIALECT_DUMP") {
let _ = std::fs::create_dir_all(&dir);
let mut buf = String::new();
buf.push_str(&f.header);
buf.push('\n');
for item in &f.items {
match item {
FrozenItem::Label(s) => {
buf.push_str(s);
buf.push('\n');
}
FrozenItem::Blank => buf.push('\n'),
FrozenItem::Text(s) => {
buf.push_str(s);
buf.push('\n');
}
FrozenItem::Inst(i) => {
i.render_into(&mut buf);
buf.push('\n');
}
}
}
let safe: String = f
.name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
let _ = std::fs::write(format!("{dir}/{safe}.ll"), &buf);
}
anyhow!("native IR construction failed in @{}: {e:#}", f.name)
}

/// Native construction for a module large enough to split into codegen
/// units (#5391): each unit is its own context+module (peak RSS stays
/// ~whole/n, same bound as the per-unit clang model), functions stream with
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/runtime_decls/arrays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ pub(crate) fn declare_core(module: &mut LlModule) {

// ========== NaN-boxing / typeof / is_* ==========
module.declare_function("js_dynamic_neg", DOUBLE, &[DOUBLE]);
module.declare_function("js_dynamic_pos", DOUBLE, &[DOUBLE]);
module.declare_function("js_dynamic_string_equals", I32, &[DOUBLE, DOUBLE]);
module.declare_function("js_is_nan", DOUBLE, &[DOUBLE]);
module.declare_function("js_jsvalue_compare", I32, &[DOUBLE, DOUBLE]);
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
module.declare_function("js_instanceof_noncallable_rhs", DOUBLE, &[]);
module.declare_function("js_register_class_extends_error", VOID, &[I32]);
module.declare_function("js_register_class_extends_data_view", VOID, &[I32]);
module.declare_function("js_register_class_extends_typed_array", VOID, &[I32]);
module.declare_function("js_register_class_id", VOID, &[I32]);
// #1021 NestJS: surface Perry class names to V8 so `metatype.name`
// is non-empty. Codegen emits one call per registered class id at
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/type_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ pub(crate) use numeric::{
pub(crate) use pod::{
add_operands_have_pod_materialization_hazard,
expr_may_return_boxed_value_from_raw_f64_fallback, expression_has_numeric_length,
is_fixed_width_buffer_numeric_read, is_numeric_typed_array_class, is_typed_array_expr,
numeric_proof_is_declared_only, pod_record_field_is_numeric,
is_fixed_width_buffer_numeric_read, is_numeric_typed_array_class, is_typed_array_class,
is_typed_array_expr, numeric_proof_is_declared_only, pod_record_field_is_numeric,
scalar_replaced_array_element_is_raw_f64, scalar_replaced_field_is_raw_f64,
scalar_replaced_field_raw_f64_store_state,
};
Expand Down
Loading
Loading