From f59d02b3b85352e3fc496b753fec205d0d7c7cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 11:46:27 +0200 Subject: [PATCH 1/3] fix(lint): split index_set.rs, over the 2000-line cap since #7342 (#7366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check_file_size.sh` exits 1 on main HEAD: `crates/perry-codegen/src/expr/index_set.rs` is 2035 lines against a 2000 cap. It crossed in #7342. That script runs inside the `lint` job, which is a REQUIRED context -- so this is the second independent way `lint` was red on main today (the first was rustfmt on linker.rs, #7361). A required check that is red on main blocks nothing; it means every merge is a bypass. The split follows the recipe in the script's own failure message: extract a topical group into a sibling module. `lower_inline_dyn_typed_array_set` and its `emit_inline_ta_int_store` helper are the guarded inline typed-array store for a type-erased receiver -- one coherent unit, moved verbatim to `index_set_typed_array.rs`. index_set.rs drops to 1749 lines, leaving real headroom rather than landing one line under the cap. Mechanical move: the two functions are byte-identical, only the imports they need travelled with them and `lower_inline_dyn_typed_array_set` became `pub(super)` so its one caller can still reach it. cargo test -p perry-codegen --lib: 609 passed. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF Co-authored-by: Ralph Küpper --- crates/perry-codegen/src/expr/index_set.rs | 290 +---------------- .../src/expr/index_set_typed_array.rs | 297 ++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 1 + 3 files changed, 300 insertions(+), 288 deletions(-) create mode 100644 crates/perry-codegen/src/expr/index_set_typed_array.rs diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index ceac76e0e1..a25ca75abb 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -13,8 +13,9 @@ use crate::native_value::{ NativeRep, SemanticKind, }; use crate::type_analysis::{is_array_expr, is_numeric_expr, is_string_expr, receiver_class_name}; -use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8}; +use crate::types::{DOUBLE, I32, I64}; +use super::index_set_typed_array::lower_inline_dyn_typed_array_set; use super::{ array_kind_fact, array_store_needs_layout_note, array_store_needs_write_barrier, buffer_access_materialization_reason, emit_array_numeric_write_note_on_block, @@ -1746,290 +1747,3 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { _ => unreachable!("expr/mod.rs dispatched a variant not handled by this submodule"), } } - -/// #5525 follow-up: guarded **inline** typed-array element STORE for an -/// `obj[i] = v` whose receiver static type is erased (`any`/unknown) but is, at -/// runtime, commonly an owning numeric typed array (bcryptjs's `P[i]=`/`S[i]=` -/// Int32Array boxes). Mirrors [`index_get::lower_inline_dyn_typed_array_get`]: -/// the same pointer / `PERRY_TA_VIEW_GUARD` / `PERRY_TA_KIND_CACHE` / index -/// guards, then a direct per-kind store into `header + 16 + idx*elem_size`, -/// falling back to `js_dyn_index_set` on any guard miss. The store result is the -/// assigned value (`val_double`), matching `js_dyn_index_set`'s return. -/// -/// Only the kinds with a simple ToInt32/ToUint32 truncating store (Int8/Uint8/ -/// Int16/Uint16/Int32/Uint32) or a direct float store (Float32/Float64) are -/// inlined — i.e. `kind <= KIND_FLOAT64` (7). Uint8ClampedArray (round-half-to- -/// even clamp), the BigInt kinds (ToBigInt / throw) and Float16 (f16 encode) are -/// excluded by the guard and defer to the runtime, which already owns them. The -/// integer truncation here (`toint32(value)` then narrow) is bit-identical to -/// the runtime `store_at`'s `to_uint32_bits(value) as `; the float store -/// is identical to `store_at`'s direct slot write — so behavior matches the -/// existing runtime fast path exactly. -fn lower_inline_dyn_typed_array_set( - ctx: &mut FnCtx<'_>, - obj_box: &str, - idx_d: &str, - val_double: &str, -) -> String { - let tag_mask = crate::nanbox::i64_literal(crate::nanbox::TAG_MASK); - let pointer_tag = crate::nanbox::POINTER_TAG_I64; - let pointer_mask = crate::nanbox::POINTER_MASK_I64; - - let fast_idx = ctx.new_block("tav.set.fast"); - let store_idx = ctx.new_block("tav.set.store"); - let slow_idx = ctx.new_block("tav.set.slow"); - let merge_idx = ctx.new_block("tav.set.merge"); - let fast_label = ctx.block_label(fast_idx); - let store_label = ctx.block_label(store_idx); - let slow_label = ctx.block_label(slow_idx); - let merge_label = ctx.block_label(merge_idx); - - // ---- entry: combined cache/kind/range guard -> fast | slow ---- - let entry_guard = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(obj_box); - let raw = blk.and(I64, &obj_bits, pointer_mask); - let tagged = blk.and(I64, &obj_bits, &tag_mask); - let is_ptr = blk.icmp_eq(I64, &tagged, pointer_tag); - let vg = blk.load(I64, "@PERRY_TA_VIEW_GUARD"); - let vg_zero = blk.icmp_eq(I64, &vg, "0"); - let slot = blk.lshr(I64, &raw, "3"); - let slot = blk.and(I64, &slot, "63"); - let entry_ptr = blk.gep( - "[64 x i64]", - "@PERRY_TA_KIND_CACHE", - &[(I64, "0"), (I64, &slot)], - ); - let entry_val = blk.load(I64, &entry_ptr); - let entry_addr = blk.lshr(I64, &entry_val, "8"); - let addr_match = blk.icmp_eq(I64, &entry_addr, &raw); - let kind = blk.and(I64, &entry_val, "255"); - // Stores inline only kinds with a trivial truncating/float store: - // kind <= KIND_FLOAT64 (7). Uint8Clamped (8), BigInt (9/10), Float16 - // (11), and the 0xFF sentinel all defer to the runtime. - let kind_ok = blk.icmp_ule(I64, &kind, "7"); - let idx_ge0 = blk.fcmp("oge", idx_d, "0.0"); - let idx_lt = blk.fcmp("olt", idx_d, "4294967296.0"); - let g = blk.and(I1, &is_ptr, &vg_zero); - let g = blk.and(I1, &g, &addr_match); - let g = blk.and(I1, &g, &kind_ok); - let g = blk.and(I1, &g, &idx_ge0); - blk.and(I1, &g, &idx_lt) - }; - ctx.block().cond_br(&entry_guard, &fast_label, &slow_label); - - // ---- fast: validate integer index + bounds -> store | slow ---- - ctx.current_block = fast_idx; - let (raw, idx_i64, kind) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(obj_box); - let raw = blk.and(I64, &obj_bits, pointer_mask); - let slot = blk.lshr(I64, &raw, "3"); - let slot = blk.and(I64, &slot, "63"); - let entry_ptr = blk.gep( - "[64 x i64]", - "@PERRY_TA_KIND_CACHE", - &[(I64, "0"), (I64, &slot)], - ); - let entry_val = blk.load(I64, &entry_ptr); - let kind = blk.and(I64, &entry_val, "255"); - let idx_i64 = blk.fptosi(DOUBLE, idx_d, I64); - (raw, idx_i64, kind) - }; - let fast_ok = { - let blk = ctx.block(); - let idx_back = blk.sitofp(I64, &idx_i64, DOUBLE); - let is_int = blk.fcmp("oeq", &idx_back, idx_d); - let hdr_ptr = blk.inttoptr(I64, &raw); - let len = blk.load(I32, &hdr_ptr); - let len_i64 = blk.zext(I32, &len, I64); - let in_bounds = blk.icmp_ult(I64, &idx_i64, &len_i64); - blk.and(I1, &is_int, &in_bounds) - }; - ctx.block().cond_br(&fast_ok, &store_label, &slow_label); - - // ---- store: per-kind direct element store (data = header + 16) ---- - ctx.current_block = store_idx; - let data_base = { - let blk = ctx.block(); - blk.add(I64, &raw, "16") - }; - // ToInt32 of the value once (shared by all integer kinds). For float kinds - // we use the raw double directly. `toint32` matches the runtime - // `to_uint32_bits` (NaN/±Inf/±0 → 0, else trunc-toward-zero mod 2^32). - let val_i32 = ctx.block().toint32(val_double); - - let b_i8 = ctx.new_block("tav.s.i8"); - let b_u8 = ctx.new_block("tav.s.u8"); - let b_i16 = ctx.new_block("tav.s.i16"); - let b_u16 = ctx.new_block("tav.s.u16"); - let b_i32 = ctx.new_block("tav.s.i32"); - let b_u32 = ctx.new_block("tav.s.u32"); - let b_f32 = ctx.new_block("tav.s.f32"); - let b_f64 = ctx.new_block("tav.s.f64"); - let l_i8 = ctx.block_label(b_i8); - let l_u8 = ctx.block_label(b_u8); - let l_i16 = ctx.block_label(b_i16); - let l_u16 = ctx.block_label(b_u16); - let l_i32 = ctx.block_label(b_i32); - let l_u32 = ctx.block_label(b_u32); - let l_f32 = ctx.block_label(b_f32); - let l_f64 = ctx.block_label(b_f64); - - // Dispatch chain on `kind` (in the store block, after data_base/val_i32). - let chk = |ctx: &mut FnCtx<'_>, k: &str, hit: &str, next_idx: usize| { - let next_label = ctx.block_label(next_idx); - let cond = ctx.block().icmp_eq(I64, &kind, k); - ctx.block().cond_br(&cond, hit, &next_label); - }; - let c1 = ctx.new_block("tav.sd1"); - let c2 = ctx.new_block("tav.sd2"); - let c3 = ctx.new_block("tav.sd3"); - let c4 = ctx.new_block("tav.sd4"); - let c5 = ctx.new_block("tav.sd5"); - let c6 = ctx.new_block("tav.sd6"); - chk(ctx, "0", &l_i8, c1); - ctx.current_block = c1; - chk(ctx, "1", &l_u8, c2); - ctx.current_block = c2; - chk(ctx, "2", &l_i16, c3); - ctx.current_block = c3; - chk(ctx, "3", &l_u16, c4); - ctx.current_block = c4; - chk(ctx, "4", &l_i32, c5); - ctx.current_block = c5; - chk(ctx, "5", &l_u32, c6); - ctx.current_block = c6; - // remaining: kind 6 → f32, else (7) → f64. - let is_f32 = ctx.block().icmp_eq(I64, &kind, "6"); - ctx.block().cond_br(&is_f32, &l_f32, &l_f64); - - // Per-kind stores. Each: off = idx << shift; addr = data_base + off; - // store narrowed value; br merge. - emit_inline_ta_int_store( - ctx, - b_i8, - &idx_i64, - &data_base, - &merge_label, - "0", - &val_i32, - I8, - ); - emit_inline_ta_int_store( - ctx, - b_u8, - &idx_i64, - &data_base, - &merge_label, - "0", - &val_i32, - I8, - ); - emit_inline_ta_int_store( - ctx, - b_i16, - &idx_i64, - &data_base, - &merge_label, - "1", - &val_i32, - I16, - ); - emit_inline_ta_int_store( - ctx, - b_u16, - &idx_i64, - &data_base, - &merge_label, - "1", - &val_i32, - I16, - ); - emit_inline_ta_int_store( - ctx, - b_i32, - &idx_i64, - &data_base, - &merge_label, - "2", - &val_i32, - I32, - ); - emit_inline_ta_int_store( - ctx, - b_u32, - &idx_i64, - &data_base, - &merge_label, - "2", - &val_i32, - I32, - ); - // F32: fptrunc the double to float, store. - { - ctx.current_block = b_f32; - let blk = ctx.block(); - let off = blk.shl(I64, &idx_i64, "2"); - let addr = blk.add(I64, &data_base, &off); - let ptr = blk.inttoptr(I64, &addr); - let f = blk.fptrunc(DOUBLE, val_double, F32); - blk.store(F32, &f, &ptr); - blk.br(&merge_label); - } - // F64: store the double raw. - { - ctx.current_block = b_f64; - let blk = ctx.block(); - let off = blk.shl(I64, &idx_i64, "3"); - let addr = blk.add(I64, &data_base, &off); - let ptr = blk.inttoptr(I64, &addr); - blk.store(DOUBLE, val_double, &ptr); - blk.br(&merge_label); - } - - // ---- slow: the unchanged runtime setter ---- - ctx.current_block = slow_idx; - ctx.block().call( - DOUBLE, - "js_dyn_index_set", - &[(DOUBLE, obj_box), (DOUBLE, idx_d), (DOUBLE, val_double)], - ); - ctx.block().br(&merge_label); - - // ---- merge: assignment yields the stored value on every path ---- - ctx.current_block = merge_idx; - // All paths produce `val_double` as the expression result (matching - // `js_dyn_index_set`'s `return value`), so no phi is needed. - val_double.to_string() -} - -/// Emit one per-kind integer typed-array element store block for -/// [`lower_inline_dyn_typed_array_set`]: switches to `blk_idx`, computes the -/// element address (`data_base + (idx << shift)`), narrows the shared -/// ToInt32-coerced `val_i32` to `elem_ty`, stores it, and branches to -/// `merge_label`. -#[allow(clippy::too_many_arguments)] -fn emit_inline_ta_int_store( - ctx: &mut FnCtx<'_>, - blk_idx: usize, - idx_i64: &str, - data_base: &str, - merge_label: &str, - shift: &str, - val_i32: &str, - elem_ty: crate::types::LlvmType, -) { - ctx.current_block = blk_idx; - let blk = ctx.block(); - let off = blk.shl(I64, idx_i64, shift); - let addr = blk.add(I64, data_base, &off); - let ptr = blk.inttoptr(I64, &addr); - let narrowed = if elem_ty == I32 { - val_i32.to_string() - } else { - blk.trunc(I32, val_i32, elem_ty) - }; - blk.store(elem_ty, &narrowed, &ptr); - blk.br(merge_label); -} diff --git a/crates/perry-codegen/src/expr/index_set_typed_array.rs b/crates/perry-codegen/src/expr/index_set_typed_array.rs new file mode 100644 index 0000000000..fd622e9037 --- /dev/null +++ b/crates/perry-codegen/src/expr/index_set_typed_array.rs @@ -0,0 +1,297 @@ +//! Guarded **inline** typed-array element store for a type-erased receiver. +//! +//! Split out of `index_set.rs` when that file crossed the 2000-line cap +//! (#7342 pushed it to 2035). Pure mechanical move — the two functions below +//! are verbatim, and `index_set.rs` calls them through a `use super::` path +//! exactly as it called them locally before. + +use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8}; + +use super::FnCtx; + +/// #5525 follow-up: guarded **inline** typed-array element STORE for an +/// `obj[i] = v` whose receiver static type is erased (`any`/unknown) but is, at +/// runtime, commonly an owning numeric typed array (bcryptjs's `P[i]=`/`S[i]=` +/// Int32Array boxes). Mirrors [`index_get::lower_inline_dyn_typed_array_get`]: +/// the same pointer / `PERRY_TA_VIEW_GUARD` / `PERRY_TA_KIND_CACHE` / index +/// guards, then a direct per-kind store into `header + 16 + idx*elem_size`, +/// falling back to `js_dyn_index_set` on any guard miss. The store result is the +/// assigned value (`val_double`), matching `js_dyn_index_set`'s return. +/// +/// Only the kinds with a simple ToInt32/ToUint32 truncating store (Int8/Uint8/ +/// Int16/Uint16/Int32/Uint32) or a direct float store (Float32/Float64) are +/// inlined — i.e. `kind <= KIND_FLOAT64` (7). Uint8ClampedArray (round-half-to- +/// even clamp), the BigInt kinds (ToBigInt / throw) and Float16 (f16 encode) are +/// excluded by the guard and defer to the runtime, which already owns them. The +/// integer truncation here (`toint32(value)` then narrow) is bit-identical to +/// the runtime `store_at`'s `to_uint32_bits(value) as `; the float store +/// is identical to `store_at`'s direct slot write — so behavior matches the +/// existing runtime fast path exactly. +pub(super) fn lower_inline_dyn_typed_array_set( + ctx: &mut FnCtx<'_>, + obj_box: &str, + idx_d: &str, + val_double: &str, +) -> String { + let tag_mask = crate::nanbox::i64_literal(crate::nanbox::TAG_MASK); + let pointer_tag = crate::nanbox::POINTER_TAG_I64; + let pointer_mask = crate::nanbox::POINTER_MASK_I64; + + let fast_idx = ctx.new_block("tav.set.fast"); + let store_idx = ctx.new_block("tav.set.store"); + let slow_idx = ctx.new_block("tav.set.slow"); + let merge_idx = ctx.new_block("tav.set.merge"); + let fast_label = ctx.block_label(fast_idx); + let store_label = ctx.block_label(store_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + + // ---- entry: combined cache/kind/range guard -> fast | slow ---- + let entry_guard = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(obj_box); + let raw = blk.and(I64, &obj_bits, pointer_mask); + let tagged = blk.and(I64, &obj_bits, &tag_mask); + let is_ptr = blk.icmp_eq(I64, &tagged, pointer_tag); + let vg = blk.load(I64, "@PERRY_TA_VIEW_GUARD"); + let vg_zero = blk.icmp_eq(I64, &vg, "0"); + let slot = blk.lshr(I64, &raw, "3"); + let slot = blk.and(I64, &slot, "63"); + let entry_ptr = blk.gep( + "[64 x i64]", + "@PERRY_TA_KIND_CACHE", + &[(I64, "0"), (I64, &slot)], + ); + let entry_val = blk.load(I64, &entry_ptr); + let entry_addr = blk.lshr(I64, &entry_val, "8"); + let addr_match = blk.icmp_eq(I64, &entry_addr, &raw); + let kind = blk.and(I64, &entry_val, "255"); + // Stores inline only kinds with a trivial truncating/float store: + // kind <= KIND_FLOAT64 (7). Uint8Clamped (8), BigInt (9/10), Float16 + // (11), and the 0xFF sentinel all defer to the runtime. + let kind_ok = blk.icmp_ule(I64, &kind, "7"); + let idx_ge0 = blk.fcmp("oge", idx_d, "0.0"); + let idx_lt = blk.fcmp("olt", idx_d, "4294967296.0"); + let g = blk.and(I1, &is_ptr, &vg_zero); + let g = blk.and(I1, &g, &addr_match); + let g = blk.and(I1, &g, &kind_ok); + let g = blk.and(I1, &g, &idx_ge0); + blk.and(I1, &g, &idx_lt) + }; + ctx.block().cond_br(&entry_guard, &fast_label, &slow_label); + + // ---- fast: validate integer index + bounds -> store | slow ---- + ctx.current_block = fast_idx; + let (raw, idx_i64, kind) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(obj_box); + let raw = blk.and(I64, &obj_bits, pointer_mask); + let slot = blk.lshr(I64, &raw, "3"); + let slot = blk.and(I64, &slot, "63"); + let entry_ptr = blk.gep( + "[64 x i64]", + "@PERRY_TA_KIND_CACHE", + &[(I64, "0"), (I64, &slot)], + ); + let entry_val = blk.load(I64, &entry_ptr); + let kind = blk.and(I64, &entry_val, "255"); + let idx_i64 = blk.fptosi(DOUBLE, idx_d, I64); + (raw, idx_i64, kind) + }; + let fast_ok = { + let blk = ctx.block(); + let idx_back = blk.sitofp(I64, &idx_i64, DOUBLE); + let is_int = blk.fcmp("oeq", &idx_back, idx_d); + let hdr_ptr = blk.inttoptr(I64, &raw); + let len = blk.load(I32, &hdr_ptr); + let len_i64 = blk.zext(I32, &len, I64); + let in_bounds = blk.icmp_ult(I64, &idx_i64, &len_i64); + blk.and(I1, &is_int, &in_bounds) + }; + ctx.block().cond_br(&fast_ok, &store_label, &slow_label); + + // ---- store: per-kind direct element store (data = header + 16) ---- + ctx.current_block = store_idx; + let data_base = { + let blk = ctx.block(); + blk.add(I64, &raw, "16") + }; + // ToInt32 of the value once (shared by all integer kinds). For float kinds + // we use the raw double directly. `toint32` matches the runtime + // `to_uint32_bits` (NaN/±Inf/±0 → 0, else trunc-toward-zero mod 2^32). + let val_i32 = ctx.block().toint32(val_double); + + let b_i8 = ctx.new_block("tav.s.i8"); + let b_u8 = ctx.new_block("tav.s.u8"); + let b_i16 = ctx.new_block("tav.s.i16"); + let b_u16 = ctx.new_block("tav.s.u16"); + let b_i32 = ctx.new_block("tav.s.i32"); + let b_u32 = ctx.new_block("tav.s.u32"); + let b_f32 = ctx.new_block("tav.s.f32"); + let b_f64 = ctx.new_block("tav.s.f64"); + let l_i8 = ctx.block_label(b_i8); + let l_u8 = ctx.block_label(b_u8); + let l_i16 = ctx.block_label(b_i16); + let l_u16 = ctx.block_label(b_u16); + let l_i32 = ctx.block_label(b_i32); + let l_u32 = ctx.block_label(b_u32); + let l_f32 = ctx.block_label(b_f32); + let l_f64 = ctx.block_label(b_f64); + + // Dispatch chain on `kind` (in the store block, after data_base/val_i32). + let chk = |ctx: &mut FnCtx<'_>, k: &str, hit: &str, next_idx: usize| { + let next_label = ctx.block_label(next_idx); + let cond = ctx.block().icmp_eq(I64, &kind, k); + ctx.block().cond_br(&cond, hit, &next_label); + }; + let c1 = ctx.new_block("tav.sd1"); + let c2 = ctx.new_block("tav.sd2"); + let c3 = ctx.new_block("tav.sd3"); + let c4 = ctx.new_block("tav.sd4"); + let c5 = ctx.new_block("tav.sd5"); + let c6 = ctx.new_block("tav.sd6"); + chk(ctx, "0", &l_i8, c1); + ctx.current_block = c1; + chk(ctx, "1", &l_u8, c2); + ctx.current_block = c2; + chk(ctx, "2", &l_i16, c3); + ctx.current_block = c3; + chk(ctx, "3", &l_u16, c4); + ctx.current_block = c4; + chk(ctx, "4", &l_i32, c5); + ctx.current_block = c5; + chk(ctx, "5", &l_u32, c6); + ctx.current_block = c6; + // remaining: kind 6 → f32, else (7) → f64. + let is_f32 = ctx.block().icmp_eq(I64, &kind, "6"); + ctx.block().cond_br(&is_f32, &l_f32, &l_f64); + + // Per-kind stores. Each: off = idx << shift; addr = data_base + off; + // store narrowed value; br merge. + emit_inline_ta_int_store( + ctx, + b_i8, + &idx_i64, + &data_base, + &merge_label, + "0", + &val_i32, + I8, + ); + emit_inline_ta_int_store( + ctx, + b_u8, + &idx_i64, + &data_base, + &merge_label, + "0", + &val_i32, + I8, + ); + emit_inline_ta_int_store( + ctx, + b_i16, + &idx_i64, + &data_base, + &merge_label, + "1", + &val_i32, + I16, + ); + emit_inline_ta_int_store( + ctx, + b_u16, + &idx_i64, + &data_base, + &merge_label, + "1", + &val_i32, + I16, + ); + emit_inline_ta_int_store( + ctx, + b_i32, + &idx_i64, + &data_base, + &merge_label, + "2", + &val_i32, + I32, + ); + emit_inline_ta_int_store( + ctx, + b_u32, + &idx_i64, + &data_base, + &merge_label, + "2", + &val_i32, + I32, + ); + // F32: fptrunc the double to float, store. + { + ctx.current_block = b_f32; + let blk = ctx.block(); + let off = blk.shl(I64, &idx_i64, "2"); + let addr = blk.add(I64, &data_base, &off); + let ptr = blk.inttoptr(I64, &addr); + let f = blk.fptrunc(DOUBLE, val_double, F32); + blk.store(F32, &f, &ptr); + blk.br(&merge_label); + } + // F64: store the double raw. + { + ctx.current_block = b_f64; + let blk = ctx.block(); + let off = blk.shl(I64, &idx_i64, "3"); + let addr = blk.add(I64, &data_base, &off); + let ptr = blk.inttoptr(I64, &addr); + blk.store(DOUBLE, val_double, &ptr); + blk.br(&merge_label); + } + + // ---- slow: the unchanged runtime setter ---- + ctx.current_block = slow_idx; + ctx.block().call( + DOUBLE, + "js_dyn_index_set", + &[(DOUBLE, obj_box), (DOUBLE, idx_d), (DOUBLE, val_double)], + ); + ctx.block().br(&merge_label); + + // ---- merge: assignment yields the stored value on every path ---- + ctx.current_block = merge_idx; + // All paths produce `val_double` as the expression result (matching + // `js_dyn_index_set`'s `return value`), so no phi is needed. + val_double.to_string() +} + +/// Emit one per-kind integer typed-array element store block for +/// [`lower_inline_dyn_typed_array_set`]: switches to `blk_idx`, computes the +/// element address (`data_base + (idx << shift)`), narrows the shared +/// ToInt32-coerced `val_i32` to `elem_ty`, stores it, and branches to +/// `merge_label`. +#[allow(clippy::too_many_arguments)] +fn emit_inline_ta_int_store( + ctx: &mut FnCtx<'_>, + blk_idx: usize, + idx_i64: &str, + data_base: &str, + merge_label: &str, + shift: &str, + val_i32: &str, + elem_ty: crate::types::LlvmType, +) { + ctx.current_block = blk_idx; + let blk = ctx.block(); + let off = blk.shl(I64, idx_i64, shift); + let addr = blk.add(I64, data_base, &off); + let ptr = blk.inttoptr(I64, &addr); + let narrowed = if elem_ty == I32 { + val_i32.to_string() + } else { + blk.trunc(I32, val_i32, elem_ty) + }; + blk.store(elem_ty, &narrowed, &ptr); + blk.br(merge_label); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 5be137438d..dc7190c134 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1739,6 +1739,7 @@ mod ta_param_f64_read; pub(crate) use index_get::packed_f64_loop_index_parts; pub(crate) use masked_window::masked_window_fact_for_index; mod index_set; +mod index_set_typed_array; mod instance_misc1; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; pub(crate) mod class_field_inline_guard; From 4366d1f6941740ec7c8b0aeb50c4a4efc184e57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 11:46:40 +0200 Subject: [PATCH 2/3] docs(plan): both statepoint adoption gates are closed; record the platform matrix (#7367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan still said statepoints were aarch64-only (#7321), that the matrix "therefore runs on macos-14", that `statepoints-refuse-x86` pinned the refusal, and it spelled the knob `PERRY_STATEPOINTS` four times. None of that is true now, and this document is what the adoption decision gets made from. What actually changed: - x86-64 is unblocked. `_Unwind_GetGR(ctx, 7)` does segfault and cannot be fixed as stated -- libgcc tracks only the columns CFI restores and RSP is derived, not tracked. #7349 stopped asking for it and derives the SP-relative base from `_Unwind_GetCFA`, with a per-arch return-address adjustment (x86-64 `call` pushes one, aarch64 `bl` does not). x86-64 Linux is a first-class arm. - Windows works via RtlVirtualUnwind (#7355), the one walker with no Itanium unwinder beneath it. - aarch64+ELF is now covered too (#7360) -- the only shape where LLVM spells 32-bit stack-map fields `.word`. - One mechanism, not two: PERRY_STATEPOINTS and the plain-map bridge are deleted, so the kill-policy line about "a mode that still exists" no longer applies to this pair. - The gate proves something now. Until today the Unix arms reported 7 frames and ZERO locations -- they would have passed with a walker that visited nothing. #7359's deep-collect probe took them to 221 locations. - watchOS/visionOS are not blocked by Perry: they build on stable without `dyn-eval`, and fail three crates away in psm's Mach-O guard. So the remaining adoption gate is `llvm-inprocess` becoming a default cargo feature, plus sequencing step 2 (root density) -- adopting today would regress binary size on root-dense code. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF Co-authored-by: Ralph Küpper --- docs/engine-plan.md | 62 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/docs/engine-plan.md b/docs/engine-plan.md index df08e69092..d4a407f9e3 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -215,6 +215,65 @@ flipping it globally, and neither is correctness: So the honest state is: *aarch64-viable, globally blocked on two pieces of scope that are both already identified.* +### ★ Update 2026-08-04 — both adoption gates are closed; statepoints run everywhere + +The section above closes with *"aarch64-viable, globally blocked on two pieces +of scope."* Both pieces are now done, so that sentence should not be carried +forward either. + +**1. x86-64 is no longer blocked (#7333 → #7349).** The `_Unwind_GetGR(ctx, 7)` +segfault is real and unfixable as stated — libgcc tracks only the columns CFI +restores, and RSP is derived from the CFA rather than tracked. The fix was to +stop asking for it: #7349 derives the SP-relative base from `_Unwind_GetCFA`, +which does work, with a per-architecture return-address adjustment (x86-64's +`call` pushes a return address, aarch64's `bl` does not — 8 bytes vs 0). +x86-64 Linux is a first-class arm of `gc-native-roots`, not a pinned refusal; +`statepoints-refuse-x86` is deleted along with the job that hosted it. + +**2. Windows works (#7354 → #7355).** `RtlVirtualUnwind` steps a `CONTEXT` +outward and yields `Rip`/`Rsp`/`Rbp` directly, so the CFA derivation above is +not needed there. It is the one walker with no Itanium unwinder beneath it. + +**Platform status, measured rather than assumed:** + +| shape | map | walker | state | +|---|---|---|---| +| aarch64 + Mach-O (macOS/iOS/iPadOS/tvOS) | `__PERRY_GCMAP` | x29 chain, unwinder fallback | ✅ CI arm | +| x86-64 + ELF | `.perry_gcmap` | unwinder + CFA-derived SP | ✅ CI arm | +| x86-64 + PE | `.pgcmap` | `RtlVirtualUnwind` | ✅ CI arm | +| aarch64 + ELF | `.perry_gcmap` | x29 chain | ✅ CI arm (#7360) | +| watchOS / visionOS | ready | ready | compiler-side ✅; see below | +| ARM64 Windows | refused | none | open | + +watchOS and visionOS are **not** blocked by Perry. `cargo check -p perry-runtime` +succeeds on stable for both with any feature set excluding `dyn-eval`; with it, +they fail three crates away in `psm`, whose Mach-O guard enumerates +`darwin/macos/ios/tvos` and omits `watchos`/`visionos`, so both fall to the ELF +branch and emit `.type`/`.size`. Verified by patching that one line: both then +build with full default features. They regressed on 2026-07-18 when `dyn-eval` +joined `default` (#6584) — nothing about the platforms changed. #7364 pins the +whole Apple target set compiler-side. + +**One mechanism, not two.** `PERRY_STATEPOINTS` is deleted and the plain-map +bridge with it; `PERRY_RS4GC` is the only spelling, and the last stale references +went in #7362. The kill-policy line above — *"a mode that still exists is a +decision that hasn't been made"* — no longer applies to this pair, because the +losing mode stopped compiling. + +**What the gate now proves.** Until 2026-08-04 the Unix arms reported +`frames_visited: 7, locations_visited: 0` — they would have passed with a walker +that visited nothing, since other root sources covered the probes. Windows +walked deep only by accident of heap sizing. `11_collect_at_depth` collects at +maximum recursion depth with one live root per frame (macOS 228/221, x86-64 +Linux 231/221, both byte-matching the oracle), so `--require-locations` now +gates every arm (#7359). + +**⇒ The remaining gate on adoption is `llvm-inprocess` becoming a default cargo +feature**, since RS4GC is the only invoke-capable backend. That is #7301's +scope and is in flight. Correctness and platform scope are no longer the +blockers; sequencing step 2 below (root density) is, because adopting today +would regress binary size on root-dense code. + ### ★ Binary size, measured 2026-08-04 — it is a ROOT-DENSITY problem, not a metadata one The note above says *"closing that axis needs **fewer roots**, not a tighter @@ -361,7 +420,8 @@ landed (#7314) and became *reachable* (#7339) and *selectable* (#7340). The spin signature is a stale `GC_TYPE_STRING` at minor #0. 4. **Then the adoption fork.** Flipping statepoints on by default additionally needs `llvm-inprocess` to become a default cargo feature (#7301's scope, since - RS4GC is the only invoke-capable backend) and x86-64 to work (#7333). + RS4GC is the only invoke-capable backend). ~~and x86-64 to work (#7333)~~ — + x86-64 landed in #7349 and Windows in #7355; see the 2026-08-04 update above. 5. **After the collector is trustworthy:** re-derive the RSS numbers (#7056). 6. **Do not** re-measure GC pacing, or update the README's performance table, mid-cycle. From 80e9fb1ce8a762a1abeac34cc8aeb7a269da6c03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 4 Aug 2026 12:19:04 +0200 Subject: [PATCH 3/3] gc: admit two provably-leaf helpers, and measure that it buys nothing js_gc_register_global_root was the most frequent non-leaf callee in the probe suite (148 call sites) and is provably GC-leaf: its whole body is runtime_write_barrier_root_heap_word -- which js_write_barrier_root_heap_word, already CannotCollect, wraps in one line -- plus a TLS Vec::push. The "malloc count threshold" trigger does not apply to that push: the counter is MALLOC_STATE.objects.len(), a registry of Perry GC objects, and the #[global_allocator] is plain mimalloc/System with no GC hook. js_typed_feedback_maybe_dump_trace joins its already-admitted family siblings. Measured A/B on the same tree, and the result is a null: probe safepoints roots total bytes __text 06_string_retention 105 -> 100 27=27 0 -4 B 09_try_catch_roots 343 -> 339 259=259 0 -4 B 11_collect_at_depth 120 -> 117 36=36 0 -4 B Root counts are IDENTICAL. The 40 safepoints removed across the suite were all rootless, and a rootless safepoint costs essentially nothing -- which is what docs/engine-plan.md already says: "the axis is not 'statepoints are bigger', it is 'roots are bigger'". Recording it as evidence: the safepoint-count lever is not the binary-size lever, so sequencing step 2 must attack live-root SETS. Two tests come with it. One pins the wrapper's classification to the barrier it wraps. The other pins js_nanbox_string OUT of the allowlist: at 120 call sites it is the obvious next candidate and reads as pure bit manipulation, but its null guard calls js_string_from_bytes to allocate an empty string. Probe suite 11/11 byte-identical under forced evacuation + verification. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF --- .../7369-gc-leaf-register-global-root.md | 48 +++++++++++++++ crates/perry-codegen/src/gc_call_effects.rs | 59 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 changelog.d/7369-gc-leaf-register-global-root.md diff --git a/changelog.d/7369-gc-leaf-register-global-root.md b/changelog.d/7369-gc-leaf-register-global-root.md new file mode 100644 index 0000000000..fa3996a5ce --- /dev/null +++ b/changelog.d/7369-gc-leaf-register-global-root.md @@ -0,0 +1,48 @@ +### Changed + +**Two runtime helpers admitted to the GC-effect allowlist — and a measured null result on binary size.** + +`js_gc_register_global_root` was the single most frequent non-leaf callee in the +probe suite (148 call sites), and it is provably GC-leaf. Its entire body is: + +```rust +runtime_write_barrier_root_heap_word(*root); // shade one header +GLOBAL_ROOTS.with(|r| r.borrow_mut().push(root)); // TLS Vec push +``` + +The first call is exactly what `js_write_barrier_root_heap_word` — already +`CannotCollect` — wraps in one line. The second is a `Vec::push`, and the +"malloc count threshold" GC trigger does not apply to it: that counter is +`MALLOC_STATE.objects.len()`, a registry of Perry GC objects, and the +`#[global_allocator]` is plain mimalloc/System with no GC hook. +`js_typed_feedback_maybe_dump_trace` joins its already-admitted family siblings +(env read, JSON serialise, file write; empty body without `diagnostics`). + +**The result, measured A/B on the same tree, is that this buys nothing:** + +| probe | safepoints | roots | total bytes | `__text` | +|---|---:|---:|---:|---:| +| `06_string_retention` | 105 → 100 | 27 → 27 | 0 | −4 B | +| `09_try_catch_roots` | 343 → 339 | 259 → 259 | 0 | −4 B | +| `11_collect_at_depth` | 120 → 117 | 36 → 36 | 0 | −4 B | + +Root counts are **identical**. The 40 safepoints removed across the suite were +all rootless, and a safepoint with no live roots costs essentially nothing — +which is precisely what `docs/engine-plan.md` already says ("Statepoints have no +fixed cost… the axis is not 'statepoints are bigger', it is 'roots are bigger'"). + +This is worth recording as evidence rather than a win: **the safepoint-count +lever is not the binary-size lever.** Sequencing step 2's "reduce root density" +must attack live-root *sets*, not safepoint counts. Anyone reaching for the next +obvious helper should read the second test below first. + +Two tests come with it. `register_global_root_tracks_the_barrier_it_wraps` pins +the two classifications together so a future demotion of the barrier cannot +leave its wrapper claiming to be leaf. `allocating_helpers_are_not_cannot_collect` +pins `js_nanbox_string` **out** of the allowlist: at 120 call sites it is the +obvious next candidate and it reads as pure bit manipulation, but its +null-pointer guard calls `js_string_from_bytes` to allocate an empty string +rather than boxing null. + +Probe suite: 11/11 byte-identical to the Node oracle under `PERRY_RS4GC=1 +PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1`. diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index adb85f49de..c4474eb66a 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -56,6 +56,28 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { | "js_write_barrier_slot" | "js_write_barrier_root_heap_word" | "js_write_barrier_root_nanbox" + // `gc/roots.rs`: registers one module-level global as a root. Audited + // 2026-08-04 and admitted because its whole body is two calls that are + // already covered: + // + // runtime_write_barrier_root_heap_word(*root) <- `js_write_barrier_ + // root_heap_word` immediately above is a ONE-LINE wrapper around + // this exact function, and is already CannotCollect. It shades + // one header and calls `push_mark_seed`, which is a TLS + // `Vec::push` (`gc/trace.rs`) — no trace, no sweep, no trigger. + // GLOBAL_ROOTS.with(|r| r.borrow_mut().push(root)) <- a TLS Vec. + // + // The `Vec::push` is the only thing worth pausing on, because CLAUDE.md + // lists a "malloc count threshold" as a GC trigger. It does not apply: + // that counter is `MALLOC_STATE.objects.len()`, a registry of Perry GC + // objects, and the `#[global_allocator]` is plain mimalloc/System with + // no GC hook. A raw Rust allocation cannot arm a trigger — which is the + // case the module doc above already carves out. + // + // Worth the audit: at 148 call sites across the probe suite this is the + // single most frequent non-leaf callee, all of it module-init code + // registering `@perry_global_*` roots. + | "js_gc_register_global_root" // `gc/layout.rs`: side-table metadata updates only. | "js_gc_note_slot_layout" | "js_gc_note_slot_layout_aware" @@ -71,6 +93,11 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { | "js_typed_feedback_class_field_set_guard" | "js_typed_feedback_observe_property_get" | "js_typed_feedback_observe_property_set" + // Same family, audited 2026-08-04: under `diagnostics` it reads an env + // var, serialises the counters with serde_json and writes a file; + // without the feature the body is empty. No Perry allocation, no + // re-entry into generated code, no route into collection. + | "js_typed_feedback_maybe_dump_trace" // Refcount writes and array-layout observations; none enters GC. | "js_string_addref" | "js_string_addref_if_heap_string" @@ -130,6 +157,38 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { mod tests { use super::*; + /// `js_gc_register_global_root` is `js_write_barrier_root_heap_word` plus + /// a TLS `Vec::push`, so the two must never be classified differently — + /// if a future audit demotes the barrier, this catches the sibling that + /// would otherwise keep claiming to be leaf. + #[test] + fn register_global_root_tracks_the_barrier_it_wraps() { + assert_eq!( + classify_direct_callee("js_gc_register_global_root"), + classify_direct_callee("js_write_barrier_root_heap_word"), + "js_gc_register_global_root's entire body is that barrier plus a \ + TLS Vec::push; they cannot have different GC effects" + ); + } + + /// The helpers that *do* allocate must stay out of `CannotCollect`, and + /// this pins the two that read as pure but are not. + /// + /// `js_nanbox_string` looks like bit manipulation and mostly is — but its + /// null-pointer guard calls `js_string_from_bytes` to allocate an empty + /// string rather than boxing null. At 120 call sites it is the obvious + /// thing to reach for next; it is not admissible. + #[test] + fn allocating_helpers_are_not_cannot_collect() { + for name in ["js_nanbox_string", "js_string_from_bytes", "js_array_alloc"] { + assert_ne!( + classify_direct_callee(name), + GcCallEffect::CannotCollect, + "{name} can allocate and must not be marked gc-leaf" + ); + } + } + #[test] fn audited_runtime_bookkeeping_cannot_collect() { for name in [