diff --git a/changelog.d/8690-loop-versioned-packed-arraylike.md b/changelog.d/8690-loop-versioned-packed-arraylike.md new file mode 100644 index 0000000000..0ee6532912 --- /dev/null +++ b/changelog.d/8690-loop-versioned-packed-arraylike.md @@ -0,0 +1,3 @@ +### Performance + +- Loop-version stable counted iteration over packed `Array` and `Array` subclasses, with fallback-free direct reads and mutation-safe current-index side exits. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 550b0bad71..e6f77dfb69 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -21,6 +21,7 @@ use super::entry::compile_module_entry; use super::helpers::{ function_body_returns_generator_object, sanitize, scoped_fn_name, unknown_func_wrapper_name, }; +use super::indexed_method_artifacts::{compile_indexed_method_clones, IndexedMethodArtifactsCtx}; use super::method::{ compile_method, compile_static_method, compile_typed_f64_method, compile_typed_f64_receiver_method, compile_typed_i1_method, compile_typed_i32_method, @@ -369,37 +370,29 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { .nonnegative_index_methods .get(&(class.name.clone(), method.name.clone())) { - compile_method( - llmod, - class, - method, - func_names, - strings, - class_table, - method_names, - module_globals, - module_global_types, - opts.import_function_prefixes, - enum_table, - static_field_globals, - class_ids, - func_signatures, - func_synthetic_arguments, - module_boxed_vars, - closure_rest_params, - cross_module, - None, - false, - None, - Some(nonnegative_index_params), - false, - ) - .with_context(|| { - format!( - "lowering nonnegative-index method clone '{}::{}'", - class.name, method.name - ) - })?; + compile_indexed_method_clones( + IndexedMethodArtifactsCtx { + llmod, + class, + method, + func_names, + strings, + classes: class_table, + methods: method_names, + module_globals, + module_global_types, + import_function_prefixes: opts.import_function_prefixes, + enums: enum_table, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + }, + nonnegative_index_params, + )?; } compile_method( llmod, @@ -427,6 +420,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering method '{}::{}'", class.name, method.name))?; // Representation-selection Phase 5a: the additive `internal` @@ -464,6 +458,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { Some(fact.clone()), None, false, + false, ) .with_context(|| { format!( @@ -504,6 +499,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { false, Some(fact.clone()), None, + false, true, ) .with_context(|| { @@ -544,6 +540,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| { format!( @@ -611,6 +608,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering getter '{}::{}'", class.name, prop))?; } @@ -666,6 +664,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering setter '{}::{}'", class.name, prop))?; } @@ -763,6 +762,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { None, None, false, + false, ) .with_context(|| format!("lowering constructor for '{}'", class.name))?; } diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 10a6456b57..0cd51ad02f 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1081,6 +1081,7 @@ pub(super) fn compile_closure( class_shape_slots: HashMap::new(), class_header_images: HashMap::new(), cached_lengths: HashMap::new(), + array_length_snapshots: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), @@ -1154,6 +1155,9 @@ pub(super) fn compile_closure( typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, + trusted_array_param_handles: HashMap::new(), + versioned_indexed_loop_facts: Vec::new(), + stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 17778dc1b8..0012ed02c7 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -872,6 +872,7 @@ pub(super) fn compile_module_entry( class_shape_slots: HashMap::new(), class_header_images: HashMap::new(), cached_lengths: HashMap::new(), + array_length_snapshots: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), @@ -954,6 +955,9 @@ pub(super) fn compile_module_entry( typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, + trusted_array_param_handles: HashMap::new(), + versioned_indexed_loop_facts: Vec::new(), + stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, @@ -1571,6 +1575,7 @@ pub(super) fn compile_module_entry( class_shape_slots: HashMap::new(), class_header_images: HashMap::new(), cached_lengths: HashMap::new(), + array_length_snapshots: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), @@ -1653,6 +1658,9 @@ pub(super) fn compile_module_entry( typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, + trusted_array_param_handles: HashMap::new(), + versioned_indexed_loop_facts: Vec::new(), + stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index b5550611ec..a265be8f14 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1126,6 +1126,7 @@ pub(super) fn compile_function( class_shape_slots: HashMap::new(), class_header_images: HashMap::new(), cached_lengths: HashMap::new(), + array_length_snapshots: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), @@ -1204,6 +1205,9 @@ pub(super) fn compile_function( typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, + trusted_array_param_handles: HashMap::new(), + versioned_indexed_loop_facts: Vec::new(), + stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 9e6b038da3..ee885f471a 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -776,6 +776,59 @@ pub(super) fn scoped_static_method_name( ) } +pub(super) fn node_stream_parent_kind( + classes: &HashMap, + class: &perry_hir::Class, +) -> Option<&'static str> { + let mut cur = class.extends_name.as_deref(); + let mut depth = 0usize; + while let Some(name) = cur { + match name { + "Readable" => return Some("readable"), + "Duplex" => return Some("duplex"), + "Transform" => return Some("transform"), + _ => {} + } + cur = classes + .get(name) + .copied() + .and_then(|parent| parent.extends_name.as_deref()); + depth += 1; + if depth > 32 { + break; + } + } + None +} + +pub(super) fn emit_public_generic_method_forwarder( + llmod: &mut LlModule, + method: &perry_hir::Function, + public_name: &str, + generic_body_name: &str, +) { + let mut params: Vec<(crate::types::LlvmType, String)> = + Vec::with_capacity(method.params.len() + 1); + params.push((DOUBLE, "%this_arg".to_string())); + for p in &method.params { + params.push((DOUBLE, format!("%arg{}", p.id))); + } + let wf = llmod.define_function(public_name, DOUBLE, params); + let _ = wf.create_block("entry"); + let mut arg_names: Vec = Vec::with_capacity(method.params.len() + 1); + arg_names.push("%this_arg".to_string()); + for p in &method.params { + arg_names.push(format!("%arg{}", p.id)); + } + let call_args: Vec<(crate::types::LlvmType, &str)> = + arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect(); + let value = wf + .block_mut(0) + .unwrap() + .call(DOUBLE, generic_body_name, &call_args); + wf.block_mut(0).unwrap().ret(DOUBLE, &value); +} + /// Walk a function body looking for `Return(Some(expr))` shapes that /// identify the function as a factory returning a class. Sets /// `*produced` to the resolved class name when the first qualifying diff --git a/crates/perry-codegen/src/codegen/index_method_clone_tests.rs b/crates/perry-codegen/src/codegen/index_method_clone_tests.rs index 3761ae175e..4138a603d6 100644 --- a/crates/perry-codegen/src/codegen/index_method_clone_tests.rs +++ b/crates/perry-codegen/src/codegen/index_method_clone_tests.rs @@ -68,6 +68,50 @@ fn read_method() -> Function { ) } +fn checked_read_method() -> Function { + const VALUE_ID: u32 = 13; + function( + 91, + "checkedRead", + vec![ + param(COLUMN_ID, "column", Type::Array(Box::new(Type::Any))), + param(INDEX_ID, "index", Type::Number), + ], + Type::Any, + vec![ + Stmt::If { + condition: Expr::Compare { + op: perry_hir::CompareOp::Eq, + left: Box::new(Expr::LocalGet(COLUMN_ID)), + right: Box::new(Expr::Undefined), + }, + then_branch: vec![Stmt::Throw(Expr::String("absent".to_string()))], + else_branch: None, + }, + Stmt::Let { + id: VALUE_ID, + name: "value".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(COLUMN_ID)), + index: Box::new(Expr::LocalGet(INDEX_ID)), + }), + }, + Stmt::If { + condition: Expr::Compare { + op: perry_hir::CompareOp::Eq, + left: Box::new(Expr::LocalGet(VALUE_ID)), + right: Box::new(Expr::Integer(99)), + }, + then_branch: vec![Stmt::Throw(Expr::String("sentinel".to_string()))], + else_branch: None, + }, + Stmt::Return(Some(Expr::LocalGet(VALUE_ID))), + ], + ) +} + fn reader_class() -> Class { Class { id: 100, @@ -186,6 +230,120 @@ fn emit() -> String { .expect("LLVM IR is UTF-8") } +fn emit_checked_reader() -> String { + let mut class = reader_class(); + class.methods = vec![checked_read_method()]; + let mut module = Module::new("checked_index_method_clone.ts"); + module.classes = vec![class]; + module.init_kind = ModuleInitKind::Eager; + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + String::from_utf8(compile_module(&module, opts).expect("checked reader compiles")) + .expect("LLVM IR is UTF-8") +} + +fn emit_versioned_checked_reader_loop() -> String { + const ENTITIES: u32 = 20; + const COLUMN: u32 = 21; + const BOUND: u32 = 22; + const CALLBACK: u32 = 23; + const FILTER: u32 = 24; + const COUNTER: u32 = 25; + const ENTITY: u32 = 26; + + let checked_call = Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "checkedRead".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(COLUMN), Expr::LocalGet(COUNTER)], + type_args: Vec::new(), + byte_offset: 0, + }; + let iterate = function( + 92, + "iterate", + vec![ + param(ENTITIES, "entities", Type::Array(Box::new(Type::Any))), + param(COLUMN, "column", Type::Array(Box::new(Type::Any))), + param(BOUND, "bound", Type::Number), + param(CALLBACK, "callback", Type::Any), + param(FILTER, "filter", Type::Any), + ], + Type::Void, + vec![Stmt::For { + init: Some(Box::new(Stmt::Let { + id: COUNTER, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: perry_hir::CompareOp::Lt, + left: Box::new(Expr::LocalGet(COUNTER)), + right: Box::new(Expr::LocalGet(BOUND)), + }), + update: Some(Expr::Update { + id: COUNTER, + op: perry_hir::UpdateOp::Increment, + prefix: false, + }), + body: vec![ + Stmt::Let { + id: ENTITY, + name: "entity".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ENTITIES)), + index: Box::new(Expr::LocalGet(COUNTER)), + }), + }, + Stmt::If { + condition: Expr::Logical { + op: perry_hir::LogicalOp::And, + left: Box::new(Expr::LocalGet(FILTER)), + right: Box::new(Expr::Unary { + op: perry_hir::UnaryOp::Not, + operand: Box::new(Expr::Call { + callee: Box::new(Expr::LocalGet(FILTER)), + args: vec![Expr::LocalGet(ENTITY)], + type_args: Vec::new(), + byte_offset: 0, + }), + }), + }, + then_branch: vec![Stmt::Continue], + else_branch: None, + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(CALLBACK)), + args: vec![Expr::LocalGet(ENTITY), checked_call], + type_args: Vec::new(), + byte_offset: 0, + }), + ], + }], + ); + let mut class = reader_class(); + class.methods = vec![checked_read_method(), iterate]; + let mut module = Module::new("versioned_checked_reader_loop.ts"); + module.classes = vec![class]; + module.init_kind = ModuleInitKind::Eager; + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + String::from_utf8(compile_module(&module, opts).expect("versioned loop compiles")) + .expect("LLVM IR is UTF-8") +} + fn function_body(ir: &str, definition_contains: &str) -> String { let start = ir .lines() @@ -296,6 +454,67 @@ fn selector_rejects_mutated_defaulted_and_closure_captured_indices() { assert!(super::typed_abi::nonnegative_index_method_params(&captured).is_empty()); } +#[test] +fn checked_reader_gets_a_handle_abi_clone_with_no_array_fallback() { + let method = checked_read_method(); + let index_params = super::typed_abi::nonnegative_index_method_params(&method); + assert_eq!(index_params, vec![INDEX_ID]); + assert_eq!( + super::typed_abi::nonnegative_index_fast_array_params(&method, &index_params), + vec![COLUMN_ID] + ); + + let ir = emit_checked_reader(); + let clone = function_body( + &ir, + "@perry_method_checked_index_method_clone_ts__Reader__checkedRead$idx_fast_array_u31_12(", + ); + assert!( + clone.lines().next().is_some_and(|line| { + line.contains("i64 %fast_array_handle11") && line.contains(" alwaysinline ") + }), + "the fallback-free clone must expose the private live-handle ABI:\n{clone}" + ); + assert!( + clone.contains("load double") + && clone.contains("select i1") + && !clone.contains("js_typed_feedback_array_index_get_fallback_boxed") + && !clone.contains("js_array_get_index_or_string") + && !clone.contains("arr.guard"), + "the private clone must contain a hole-aware direct load and no ordinary fallback:\n{clone}" + ); +} + +#[test] +fn checked_reader_callback_loop_versions_to_fast_and_resumable_slow_bodies() { + let ir = emit_versioned_checked_reader_loop(); + let iterate = function_body( + &ir, + "@perry_method_versioned_checked_reader_loop_ts__Reader__iterate(", + ); + assert!( + iterate.contains("versioned_index.loop.fast.preheader") + && iterate.contains("versioned_index.loop.slow.preheader") + && iterate.contains("versioned_index.iteration.fast") + && iterate.contains("label %versioned_index.loop.slow.preheader"), + "the loop must have an iteration-entry guard and a current-index slow side exit:\n{iterate}" + ); + assert!( + iterate.contains( + "@perry_method_versioned_checked_reader_loop_ts__Reader__checkedRead$idx_fast_array_u31_12(" + ), + "the fast body must route the checked reader through its live-handle ABI:\n{iterate}" + ); + let fast_call = iterate + .lines() + .find(|line| line.contains("$idx_fast_array_u31_12(")) + .expect("fast clone call exists"); + assert!( + fast_call.contains("i64 %"), + "the versioned call must pass a live array handle:\n{fast_call}" + ); +} + #[test] fn guarded_read_can_follow_one_forwarding_edge_but_rechecks_the_live_header() { let ir = emit(); diff --git a/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs b/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs new file mode 100644 index 0000000000..f479b8f028 --- /dev/null +++ b/crates/perry-codegen/src/codegen/indexed_method_artifacts.rs @@ -0,0 +1,130 @@ +//! Emits the paired nonnegative-index method bodies used by guarded indexed +//! loop cloning. Kept separate from the artifact traversal so adding a clone +//! does not push that orchestration module back over the source-size gate. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{Context, Result}; + +use crate::module::LlModule; +use crate::strings::StringPool; + +use super::method::compile_method; +use super::opts::CrossModuleCtx; + +pub(super) struct IndexedMethodArtifactsCtx<'a> { + pub llmod: &'a mut LlModule, + pub class: &'a perry_hir::Class, + pub method: &'a perry_hir::Function, + pub func_names: &'a HashMap, + pub strings: &'a mut StringPool, + pub classes: &'a HashMap, + pub methods: &'a HashMap<(String, String), String>, + pub module_globals: &'a HashMap, + pub module_global_types: &'a HashMap, + pub import_function_prefixes: &'a HashMap, + pub enums: &'a HashMap<(String, String), perry_hir::EnumValue>, + pub static_field_globals: &'a HashMap<(String, String), String>, + pub class_ids: &'a HashMap, + pub func_signatures: &'a HashMap, + pub func_synthetic_arguments: &'a HashSet, + pub module_boxed_vars: &'a HashSet, + pub closure_rest_params: &'a HashMap, + pub cross_module: &'a CrossModuleCtx, +} + +pub(super) fn compile_indexed_method_clones( + c: IndexedMethodArtifactsCtx<'_>, + nonnegative_index_params: &[u32], +) -> Result<()> { + let IndexedMethodArtifactsCtx { + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + } = c; + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + None, + Some(nonnegative_index_params), + false, + false, + ) + .with_context(|| { + format!( + "lowering nonnegative-index method clone '{}::{}'", + class.name, method.name + ) + })?; + + if super::typed_abi::nonnegative_index_fast_array_params(method, nonnegative_index_params) + .is_empty() + { + return Ok(()); + } + compile_method( + llmod, + class, + method, + func_names, + strings, + classes, + methods, + module_globals, + module_global_types, + import_function_prefixes, + enums, + static_field_globals, + class_ids, + func_signatures, + func_synthetic_arguments, + module_boxed_vars, + closure_rest_params, + cross_module, + None, + false, + None, + Some(nonnegative_index_params), + true, + false, + ) + .with_context(|| { + format!( + "lowering fallback-free indexed-array method clone '{}::{}'", + class.name, method.name + ) + }) +} diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index b4779ce74a..7f4f1b6058 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -12,7 +12,9 @@ use crate::stmt; use crate::strings::StringPool; use crate::types::{LlvmType, DOUBLE, I1, I32, I64, PTR}; -use super::helpers::scoped_static_method_name; +use super::helpers::{ + emit_public_generic_method_forwarder, node_stream_parent_kind, scoped_static_method_name, +}; use super::opts::CrossModuleCtx; use super::typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_method_body_name, lower_typed_f64_body, @@ -183,58 +185,6 @@ fn emit_public_typed_method_trampoline( .ret(DOUBLE, &fallback_value); } -fn emit_public_generic_method_forwarder( - llmod: &mut LlModule, - method: &Function, - public_name: &str, - generic_body_name: &str, -) { - let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); - params.push((DOUBLE, "%this_arg".to_string())); - for p in &method.params { - params.push((DOUBLE, format!("%arg{}", p.id))); - } - let wf = llmod.define_function(public_name, DOUBLE, params); - let _ = wf.create_block("entry"); - let mut arg_names: Vec = Vec::with_capacity(method.params.len() + 1); - arg_names.push("%this_arg".to_string()); - for p in &method.params { - arg_names.push(format!("%arg{}", p.id)); - } - let call_args: Vec<(LlvmType, &str)> = - arg_names.iter().map(|arg| (DOUBLE, arg.as_str())).collect(); - let value = wf - .block_mut(0) - .unwrap() - .call(DOUBLE, generic_body_name, &call_args); - wf.block_mut(0).unwrap().ret(DOUBLE, &value); -} - -fn node_stream_parent_kind( - classes: &HashMap, - class: &perry_hir::Class, -) -> Option<&'static str> { - let mut cur = class.extends_name.as_deref(); - let mut depth = 0usize; - while let Some(name) = cur { - match name { - "Readable" => return Some("readable"), - "Duplex" => return Some("duplex"), - "Transform" => return Some("transform"), - _ => {} - } - cur = classes - .get(name) - .copied() - .and_then(|parent| parent.extends_name.as_deref()); - depth += 1; - if depth > 32 { - break; - } - } - None -} - /// Compile a class instance method as a top-level LLVM function with the /// signature `perry_method__(this_box: double, args: double…) /// -> double`. The first parameter (`this`) is stored in a slot whose @@ -263,6 +213,7 @@ pub(super) fn compile_method( force_generic_body: bool, proven_this: Option, nonnegative_index_params: Option<&[u32]>, + fast_array_handle_clone: bool, ptr_array_cache_clone: bool, ) -> Result<()> { let public_llvm_name = methods @@ -282,9 +233,24 @@ pub(super) fn compile_method( // primary (`proven_this: None`) invocation for this same method. let is_pshape_clone = proven_this.is_some(); let is_index_clone = nonnegative_index_params.is_some(); + let fast_array_param_ids = if fast_array_handle_clone { + crate::codegen::typed_abi::nonnegative_index_fast_array_params( + method, + nonnegative_index_params.expect("fast-array clone has index parameters"), + ) + } else { + Vec::new() + }; debug_assert!(!(is_pshape_clone && is_index_clone)); + debug_assert!(!fast_array_handle_clone || is_index_clone); + debug_assert!(!fast_array_handle_clone || !fast_array_param_ids.is_empty()); debug_assert!(!ptr_array_cache_clone || is_pshape_clone); - let llvm_name = if let Some(params) = nonnegative_index_params { + let llvm_name = if fast_array_handle_clone { + crate::codegen::nonnegative_index_fast_array_method_name( + &public_llvm_name, + nonnegative_index_params.expect("fast-array clone has index parameters"), + ) + } else if let Some(params) = nonnegative_index_params { crate::codegen::nonnegative_index_method_name(&public_llvm_name, params) } else if ptr_array_cache_clone { crate::collectors::ptr_array_cache_method_name(&public_llvm_name) @@ -297,11 +263,15 @@ pub(super) fn compile_method( }; // Build the param list: (this, arg0, arg1, ...). All are doubles. - let mut params: Vec<(LlvmType, String)> = Vec::with_capacity(method.params.len() + 1); + let mut params: Vec<(LlvmType, String)> = + Vec::with_capacity(method.params.len() + 1 + fast_array_param_ids.len()); params.push((DOUBLE, "%this_arg".to_string())); for p in &method.params { params.push((DOUBLE, format!("%arg{}", p.id))); } + for id in &fast_array_param_ids { + params.push((I64, format!("%fast_array_handle{id}"))); + } let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; @@ -582,6 +552,7 @@ pub(super) fn compile_method( class_shape_slots: HashMap::new(), class_header_images: HashMap::new(), cached_lengths: HashMap::new(), + array_length_snapshots: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), @@ -655,6 +626,13 @@ pub(super) fn compile_method( typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, + trusted_array_param_handles: fast_array_param_ids + .iter() + .copied() + .map(|id| (id, format!("%fast_array_handle{id}"))) + .collect(), + versioned_indexed_loop_facts: Vec::new(), + stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this, typed_i32_methods: &cross_module.typed_i32_methods, @@ -1835,6 +1813,7 @@ pub(super) fn compile_static_method( class_shape_slots: HashMap::new(), class_header_images: HashMap::new(), cached_lengths: HashMap::new(), + array_length_snapshots: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), masked_window_array_facts: Vec::new(), @@ -1908,6 +1887,9 @@ pub(super) fn compile_static_method( typed_f64_methods: &cross_module.typed_f64_methods, pshape_methods: &cross_module.pshape_methods, nonnegative_index_methods: &cross_module.nonnegative_index_methods, + trusted_array_param_handles: HashMap::new(), + versioned_indexed_loop_facts: Vec::new(), + stable_packed_loop_facts: Vec::new(), pshape_tower_routable: &cross_module.pshape_tower_routable, proven_this: None, typed_i32_methods: &cross_module.typed_i32_methods, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ca3d4b9b7d..fbb76531b0 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -190,6 +190,7 @@ mod function; mod hoisted_callback_method_tests; #[cfg(test)] mod index_method_clone_tests; +mod indexed_method_artifacts; // `pub(crate)` so `crate::linker` can read the inline-hot-small policy // (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`). #[cfg(test)] @@ -239,7 +240,8 @@ pub(crate) use param_guard::scalar_descriptor_rep; pub(crate) use spec_abi::{spec_abi_enabled, spec_function_name, SpecDispatch, SpecFnPlan}; pub(crate) use typed_abi::{ emit_typed_arg_guard, emit_typed_arg_to_raw, generic_closure_body_name, - generic_function_body_name, generic_method_body_name, nonnegative_index_method_name, + generic_function_body_name, generic_method_body_name, nonnegative_index_fast_array_method_name, + nonnegative_index_fast_array_params, nonnegative_index_method_name, typed_arg_is_guard_candidate, typed_f64_closure_name, typed_f64_function_name, typed_f64_method_name, typed_f64_receiver_method_info, typed_f64_receiver_method_name, typed_i1_closure_name, typed_i1_function_name, typed_i1_method_name, typed_i32_closure_name, diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 35b56bb882..8598b74646 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -443,6 +443,22 @@ pub(crate) fn nonnegative_index_method_name(generic_name: &str, params: &[u32]) format!("{generic_name}$idx_u31_{suffix}") } +/// Private indexed-method body reached only from a versioned loop that has +/// already admitted the receiver, the complete index range, and every array +/// argument. The extra arguments are live array handles; the body contains no +/// ordinary array guard or semantic fallback. +pub(crate) fn nonnegative_index_fast_array_method_name( + generic_name: &str, + params: &[u32], +) -> String { + let suffix = params + .iter() + .map(u32::to_string) + .collect::>() + .join("_"); + format!("{generic_name}$idx_fast_array_u31_{suffix}") +} + /// Select a deliberately small method family for call-site-proven index /// specialization. Source `number` annotations nominate candidates but never /// license the clone: routing requires a separate nonnegative-i32 proof at the @@ -476,6 +492,102 @@ pub(crate) fn nonnegative_index_method_params(method: &Function) -> Vec { .collect() } +/// Array parameters eligible for the fallback-free indexed-method clone. +/// +/// The deliberately small body contract is a common checked-reader shape: +/// reject an absent array, load `array[index]`, optionally reject one sentinel, +/// and return the loaded value. No user code can run on the continuing path +/// before or after the indexed read. Throw expressions remain unrestricted +/// because they terminate the invocation; their allocation/evaluation cannot +/// invalidate a later fast read in this clone. +pub(crate) fn nonnegative_index_fast_array_params( + method: &Function, + index_params: &[u32], +) -> Vec { + use perry_hir::Expr; + + if index_params.is_empty() || method.body.len() != 4 { + return Vec::new(); + } + let ( + Stmt::If { + condition: absent_condition, + then_branch: absent_branch, + else_branch: None, + }, + Stmt::Let { + id: value_id, + init: Some(Expr::IndexGet { object, index }), + .. + }, + Stmt::If { + condition: sentinel_condition, + then_branch: sentinel_branch, + else_branch: None, + }, + Stmt::Return(Some(Expr::LocalGet(return_id))), + ) = ( + &method.body[0], + &method.body[1], + &method.body[2], + &method.body[3], + ) + else { + return Vec::new(); + }; + if *return_id != *value_id + || !matches!(absent_branch.as_slice(), [Stmt::Throw(_)]) + || !matches!(sentinel_branch.as_slice(), [Stmt::Throw(_)]) + { + return Vec::new(); + } + + let (Expr::LocalGet(array_id), Expr::LocalGet(index_id)) = (object.as_ref(), index.as_ref()) + else { + return Vec::new(); + }; + if !index_params.contains(index_id) { + return Vec::new(); + } + let absent_checks_array = matches!( + absent_condition, + Expr::Compare { + op: CompareOp::Eq | CompareOp::LooseEq, + left, + right, + } if matches!( + (left.as_ref(), right.as_ref()), + (Expr::LocalGet(id), Expr::Undefined) | (Expr::Undefined, Expr::LocalGet(id)) + if *id == *array_id + ) + ); + let sentinel_checks_value = matches!( + sentinel_condition, + Expr::Compare { + op: CompareOp::Eq | CompareOp::LooseEq, + left, + right, + } if matches!(left.as_ref(), Expr::LocalGet(id) if *id == *value_id) + || matches!(right.as_ref(), Expr::LocalGet(id) if *id == *value_id) + ); + if !absent_checks_array || !sentinel_checks_value { + return Vec::new(); + } + + let reassigned = crate::collectors::reassigned_locals(&method.body); + let closure_referenced = crate::expr::collect_closure_referenced_locals(&method.body); + method + .params + .iter() + .filter(|param| { + param.id == *array_id + && !reassigned.contains(¶m.id) + && !closure_referenced.contains(¶m.id) + }) + .map(|param| param.id) + .collect() +} + pub(crate) fn generic_closure_body_name(generic_name: &str) -> String { format!("{generic_name}$generic") } diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index a6bb8193fc..1967b56d3f 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -738,7 +738,7 @@ fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() { && probe.contains("getelementptr i8, ptr") && probe.contains("i64 -8") && probe.contains("and i32") - && probe.contains(", 134250751") + && probe.contains(", 142639359") && probe.contains("icmp eq i32") && probe.contains(", 2") && probe.contains("load i64, ptr") @@ -751,7 +751,7 @@ fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() { && probe.contains(", -2147483648") && probe.contains("icmp ult i32") && probe.contains(", 1073741824"), - "the packed header block must check the GC type, forwarding flag, own-descriptor bit, exact class/ShapeId pair, and ShapeId domain:\n{probe}" + "the packed header block must check the GC type, forwarding flag, own-descriptor/packed-proof bits, exact class/ShapeId pair, and ShapeId domain:\n{probe}" ); } diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index a6581cd15f..f03b45ebf1 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -376,6 +376,14 @@ fn chain_fold_is_sound(ctx: &FnCtx<'_>, parts: &[&Expr]) -> bool { } fn lower_arithmetic_operand(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<(String, bool)> { + // A stable-packed numeric clone has a stronger fact than the generic + // untyped-local typed-array probe below: its preheader scanned the exact + // range, and its emitted-IR gate proved that no call can invalidate that + // proof. Preserve the clone's private direct load and report that no + // residual ToNumber coercion is needed. + if crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, expr) { + return Ok((lower_expr(ctx, expr)?, true)); + } // #5497 Lever E: a representation-first Boolean local/literal is already // an i1. JavaScript arithmetic applies ToNumber, which is exactly an // unsigned i1 -> f64 conversion; boxing and calling js_number_coerce only diff --git a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs index eefe77eb2b..8224603cdc 100644 --- a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs +++ b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs @@ -232,6 +232,19 @@ pub(super) fn ir() -> String { .expect("LLVM IR should be UTF-8") } +/// #8690: pointer-free tagged writes (SSO and booleans) skip the shared +/// value-is-pointer bookkeeping arm. The receiver precheck must therefore +/// reject the packed-numeric authority bit before entering the inline store. +#[test] +fn class_field_set_precheck_blocks_packed_numeric_proof_receivers() { + let ir = ir(); + assert!( + ir.lines() + .any(|line| line.contains("and i16") && line.trim_end().ends_with(", 128")), + "class-field set admission must mask OBJ_FLAG_PACKED_NUMERIC_PROOF (0x80)\n{ir}" + ); +} + /// The `br i1 %cond, label %class_field_set.barrier.N, label %...` line, plus /// the body of the block that contains it. /// diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index c7ab68d307..1d8fc453b9 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -48,9 +48,10 @@ const GC_FLAG_FORWARDED_I8: &str = "-128"; // 0x80 as i8 const TYPED_LAYOUT_INTACT_BIT: &str = "4096"; // GC_OBJ_TYPED_LAYOUT_INTACT (0x1000) const OBJ_FLAG_FROZEN_BIT: &str = "1"; // OBJ_FLAG_FROZEN (0x01) const OBJ_FLAG_HAS_DESCRIPTORS_BIT: &str = "2048"; // OBJ_FLAG_HAS_DESCRIPTORS (0x800) -/// `OBJ_FLAG_FROZEN | OBJ_FLAG_HAS_DESCRIPTORS` — both live in the same -/// `GcHeader::_reserved` i16, so one mask tests both. -const OBJ_FLAG_FROZEN_OR_DESCRIPTORS: &str = "2049"; +const OBJ_FLAG_PACKED_NUMERIC_PROOF_BIT: &str = "128"; // OBJ_FLAG_PACKED_NUMERIC_PROOF (0x080) +/// `OBJ_FLAG_FROZEN | OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_PACKED_NUMERIC_PROOF` +/// — all live in the same `GcHeader::_reserved` i16, so one mask tests them. +const OBJ_FLAG_WRITE_FAST_PATH_BLOCKED: &str = "2177"; const F64_EXP_MASK: &str = "9218868437227405312"; // 0x7FF0_0000_0000_0000 /// A widening arm for the class-field shape check: one concrete subclass whose @@ -308,9 +309,9 @@ pub(crate) fn emit_class_field_loop_preheader_check( } if require_not_frozen { - let frozen = blk.and(I16, &reserved, OBJ_FLAG_FROZEN_BIT); - let not_frozen = blk.icmp_eq(I16, &frozen, "0"); - acc = blk.and(I1, &acc, ¬_frozen); + let blocked = blk.and(I16, &reserved, OBJ_FLAG_WRITE_FAST_PATH_BLOCKED); + let write_fast_path_ok = blk.icmp_eq(I16, &blocked, "0"); + acc = blk.and(I1, &acc, &write_fast_path_ok); } // No terminator: the caller branches after verifying the fast clone. @@ -394,7 +395,7 @@ pub(crate) fn emit_proven_shape_recheck( let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); let reserved = blk.load(I16, &res_ptr); - let latched = blk.and(I16, &reserved, OBJ_FLAG_FROZEN_OR_DESCRIPTORS); + let latched = blk.and(I16, &reserved, OBJ_FLAG_WRITE_FAST_PATH_BLOCKED); let unlatched = blk.icmp_eq(I16, &latched, "0"); // `class_id` @0 was already matched by the tower. ShapeId @4 proves the @@ -554,6 +555,13 @@ pub(crate) fn emit_class_field_inline_precheck( let not_frozen = blk.icmp_eq(I16, &frozen, "0"); acc = blk.and(I1, &acc, ¬_frozen); + // #8690: an inline field write can overlap an Array-subclass + // numeric prefix. Route proof-authoritative receivers through the + // runtime setter so pointer-free SSO/boolean stores retire it too. + let numeric_proof = blk.and(I16, &reserved, OBJ_FLAG_PACKED_NUMERIC_PROOF_BIT); + let no_numeric_proof = blk.icmp_eq(I16, &numeric_proof, "0"); + acc = blk.and(I1, &acc, &no_numeric_proof); + if require_raw_f64 { // Only a plain finite number may be stored raw. Non-finite // (exponent all-ones: ±Inf/NaN — rare) and every NaN-boxed tag diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 76264063d1..0b498d85be 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -1762,8 +1762,10 @@ fn lower_expr_native_f64(ctx: &mut FnCtx<'_>, e: &Expr) -> Result ); return Ok(lowered); } - let needs_raw_f64_fallback_coercion = expr_may_return_boxed_value_from_raw_f64_fallback(ctx, e) - || matches!(e, Expr::IndexGet { .. }) && is_numeric_expr(ctx, e); + let stable_numeric_index = crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, e); + let needs_raw_f64_fallback_coercion = !stable_numeric_index + && (expr_may_return_boxed_value_from_raw_f64_fallback(ctx, e) + || matches!(e, Expr::IndexGet { .. }) && is_numeric_expr(ctx, e)); let raw = lower_expr(ctx, e)?; let value = if needs_raw_f64_fallback_coercion { ctx.block() @@ -1788,8 +1790,10 @@ fn lower_expr_native_f64(ctx: &mut FnCtx<'_>, e: &Expr) -> Result } fn lower_expr_native_f32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result { - let needs_raw_f64_fallback_coercion = expr_may_return_boxed_value_from_raw_f64_fallback(ctx, e) - || matches!(e, Expr::IndexGet { .. }) && is_numeric_expr(ctx, e); + let stable_numeric_index = crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, e); + let needs_raw_f64_fallback_coercion = !stable_numeric_index + && (expr_may_return_boxed_value_from_raw_f64_fallback(ctx, e) + || matches!(e, Expr::IndexGet { .. }) && is_numeric_expr(ctx, e)); let raw = lower_expr(ctx, e)?; let d = if needs_raw_f64_fallback_coercion { ctx.block() diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index fb1b8deb84..39b96265e9 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -859,6 +859,43 @@ fn lower_legacy_array_index_get( pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::IndexGet { object, index } => { + if let Some(value) = + crate::stmt::stable_packed_loop::try_lower_index_get(ctx, object, index) + { + return Ok(value); + } + if let (Expr::LocalGet(array_id), Expr::LocalGet(index_id)) = + (object.as_ref(), index.as_ref()) + { + let versioned_handle = ctx + .versioned_indexed_loop_facts + .last() + .filter(|fact| fact.counter_local_id == *index_id) + .and_then(|fact| fact.live_array_handles.get(array_id)) + .cloned(); + if let (Some(array_handle), Some(index_slot)) = ( + versioned_handle, + ctx.i32_counter_slots.get(index_id).cloned(), + ) { + let idx_i32 = ctx.block().load(I32, &index_slot); + return Ok(guarded_array::lower_trusted_plain_array_index_get( + ctx, + &array_handle, + &idx_i32, + )); + } + if let (Some(array_handle), Some(index_slot)) = ( + ctx.trusted_array_param_handles.get(array_id).cloned(), + ctx.i32_counter_slots.get(index_id).cloned(), + ) { + let idx_i32 = ctx.block().load(I32, &index_slot); + return Ok(guarded_array::lower_trusted_plain_array_index_get( + ctx, + &array_handle, + &idx_i32, + )); + } + } if receiver_class_name(ctx, object).as_deref() == Some("Server") && is_async_dispose_symbol_index(index) { diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index 24bdaf1f74..6fcf6aac67 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -30,6 +30,28 @@ use super::{ TypedFeedbackKind, }; +/// Load one generic JavaScript array element through a handle admitted by a +/// versioned caller. Bounds, descriptor/prototype state, forwarding state, and +/// the live array header were checked at that iteration's entry. This function +/// intentionally has no branch to an ordinary array fallback. +pub(super) fn lower_trusted_plain_array_index_get( + ctx: &mut FnCtx<'_>, + array_handle: &str, + idx_i32: &str, +) -> String { + let blk = ctx.block(); + let idx_i64 = blk.zext(I32, idx_i32, I64); + let byte_offset = blk.shl(I64, &idx_i64, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let element_addr = blk.add(I64, array_handle, &with_header); + let element_ptr = blk.inttoptr(I64, &element_addr); + let raw = blk.load(DOUBLE, &element_ptr); + let raw_bits = blk.bitcast_double_to_i64(&raw); + let is_hole = blk.icmp_eq(I64, &raw_bits, crate::nanbox::TAG_HOLE_I64); + let undefined = blk.bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64); + blk.select(I1, &is_hole, DOUBLE, &undefined, &raw) +} + pub(super) fn lower_guarded_array_index_get( ctx: &mut FnCtx<'_>, arr_box: &str, diff --git a/crates/perry-codegen/src/expr/literals_vars.rs b/crates/perry-codegen/src/expr/literals_vars.rs index 3c28430864..c19e763a0e 100644 --- a/crates/perry-codegen/src/expr/literals_vars.rs +++ b/crates/perry-codegen/src/expr/literals_vars.rs @@ -13,13 +13,14 @@ use crate::lower_string_concat::{ lower_string_self_append_chain, }; use crate::nanbox::double_literal; +use crate::native_value::ExpectedNativeRep; use crate::type_analysis::{is_map_expr, is_set_expr, receiver_class_name}; use crate::types::{DOUBLE, I32, I64}; use super::{ can_lower_expr_as_i32_in_current_region, emit_root_nanbox_store_on_block, emit_shadow_slot_clear, emit_shadow_slot_update_for_expr, emit_write_barrier, - is_global_this_builtin_function_name, lower_expr, lower_expr_as_i32, + is_global_this_builtin_function_name, lower_expr, lower_expr_as_i32, lower_expr_native, lower_pod_local_reassignment, materialize_pod_value_copy, nanbox_string_inline, FnCtx, TrustedBoxCapturePtr, }; @@ -635,11 +636,33 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // via one sitofp per write so non-int readers (e.g. `acc / K`) // still see the current value. if let Some(i32_slot) = ctx.i32_counter_slots.get(id).cloned() { + let structurally_i32 = can_lower_expr_as_i32_in_current_region(ctx, value); + // Within a stable-packed numeric clone, `x | 0` may feed the + // canonical i32 slot directly: materializing a double here + // only to convert it back would duplicate the spec ToInt32. + // Keep both gates explicit. Declared Number types are erased, + // so a local can still hold a String or BigInt at runtime; and + // other loop clones own narrower indexed-load contracts that + // their existing assignment lowering must continue to see. + let explicit_numeric_toint32 = matches!( + value.as_ref(), + Expr::Binary { + op: BinaryOp::BitOr, + left, + right, + .. + } if matches!(right.as_ref(), Expr::Integer(0)) + && crate::type_analysis::expr_produces_canonical_raw_f64(ctx, left) + ) && !ctx.stable_packed_loop_facts.is_empty(); if !ctx.closure_captures.contains_key(id) && !(ctx.boxed_vars.contains(id) && !ctx.module_globals.contains_key(id)) - && can_lower_expr_as_i32_in_current_region(ctx, value) + && (structurally_i32 || explicit_numeric_toint32) { - let v_i32 = lower_expr_as_i32(ctx, value)?; + let v_i32 = if structurally_i32 { + lower_expr_as_i32(ctx, value)? + } else { + lower_expr_native(ctx, value, ExpectedNativeRep::I32)?.value + }; let unsigned_i32 = ctx.unsigned_i32_locals.contains(id); let blk = ctx.block(); blk.store(I32, &v_i32, &i32_slot); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index b12c1edbbe..e9f48237eb 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -904,6 +904,12 @@ pub(crate) struct FnCtx<'a> { /// call that LLVM can't prove won't modify the length). pub cached_lengths: std::collections::HashMap, + /// Immutable locals initialized from an exact `receiver.length` read, + /// keyed by the snapshot local. The read itself retains ordinary property + /// semantics; a later counted-loop guard may use the association only + /// after proving the receiver is a packed Array/Array-subclass. + pub array_length_snapshots: std::collections::HashMap, + /// `(counter_local_id, array_local_id)` pairs that are guaranteed /// inbounds inside the current loop nest — populated by /// `lower_for` when it detects the same `for (...; i < arr.length; @@ -1068,6 +1074,22 @@ pub(crate) struct FnCtx<'a> { /// stubs. pub nonnegative_index_methods: &'a std::collections::HashMap<(String, String), Vec>, + /// Raw live array handles supplied only to a fallback-free indexed-method + /// clone. The caller's versioned-loop admission proves the complete index + /// range and revalidates every handle before entering each fast iteration. + /// Public and ordinary `$idx_u31` bodies always leave this map empty. + pub trusted_array_param_handles: std::collections::HashMap, + + /// Active fallback-free loop versions. The newest fact belongs to the + /// innermost fast loop. Its scalar fingerprints are revalidated at each + /// iteration entry before these live array handles may be consumed. + pub versioned_indexed_loop_facts: Vec, + + /// Scoped direct Array/Array-subclass iteration facts. The preheader + /// descriptor contains scalar layout data only; `live_receiver_handle` + /// is refreshed by the iteration-entry check before direct loads. + pub stable_packed_loop_facts: Vec, + /// #7142: the subset of [`Self::pshape_methods`] the class-id dispatch /// tower may route to. A profitability filter only — see /// `collectors::pshape_tower_route_profitable`. Soundness at that site comes @@ -1538,6 +1560,67 @@ pub(crate) struct BoundedIndexPair { pub scope_id: u32, } +#[derive(Clone, Debug)] +pub(crate) struct VersionedIndexedArrayFact { + pub local_id: u32, + pub local_slot: String, + pub expected_fingerprint: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct VersionedIndexedMethodFact { + pub class_name: String, + pub method_name: String, + pub this_slot: String, + pub expected_class_id: String, + pub expected_shape_id: String, + pub method_guard_slot: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct VersionedIndexedLoopFact { + pub counter_local_id: u32, + pub falsy_local_id: Option, + pub side_exit_label: String, + pub arrays: Vec, + pub method: VersionedIndexedMethodFact, + /// Populated by the iteration-entry revalidation block. These SSA handles + /// dominate the complete fast body and are never retained across the loop + /// callback/back edge. + pub live_array_handles: std::collections::HashMap, +} + +#[derive(Clone, Debug)] +pub(crate) struct StablePackedNumericAccess { + /// Whether the admitted receiver is a plain Array rather than an + /// Array-subclass object. + pub is_plain: String, + /// Address immediately before element zero for a plain Array. + pub plain_base: String, + /// Number of admitted Array-subclass elements stored inline. + pub object_inline_count: String, + /// Address immediately before element zero in inline object storage. + pub object_inline_base: String, + /// Address immediately before element zero in spill object storage. + pub object_spill_base: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct StablePackedLoopFact { + pub counter_local_id: u32, + pub array_local_id: u32, + pub side_exit_label: String, + pub descriptor: String, + pub live_receiver_handle: Option, + /// Admission scanned the complete indexed range and proved every value is + /// an untagged IEEE Number. This is requested only when the indexed value + /// appears below a numeric operator in the cloned body. + pub numeric_elements: bool, + /// Preheader-derived numeric storage bases. Admission proved the complete + /// range is raw f64 and the call-free clone keeps these addresses stable. + pub numeric_access: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PackedNumericLoopKind { F64, diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 844b8524de..8e0a0450c5 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -48,7 +48,12 @@ use super::{ /// Runtime write-PIC flags that force the miss path. Class-vs-instance kind is /// encoded by the authoritative ShapeId and therefore owns no header flag. -const WRITE_PIC_BLOCKING_FLAGS: u16 = 0x1907; +// Includes the packed Array-subclass numeric-proof authority bit (0x80). +// A proof-active receiver takes one ordinary miss so the runtime store's +// unconditional layout note retires the proof even for pointer-free tagged +// values such as SSO strings and booleans. After that miss, the PIC is eligible +// again. This keeps proof retirement out of every ordinary-object hit. +const WRITE_PIC_BLOCKING_FLAGS: u16 = 0x1987; /// #8098: `GcHeader::_reserved` bit 9 — the runtime birth-marked this /// class-less receiver an ORDINARY plain object (`JSON.parse` output), so it is @@ -730,7 +735,7 @@ fn lower_put_value_static_write_ic( // Downgrade`, where a pointer lands in a slot a typed descriptor // declared raw-f64 — is unreachable from a PIC hit twice over. It // is guarded by `claimed_intact`, and `GC_OBJ_TYPED_LAYOUT_INTACT` - // (0x1000) is a member of `WRITE_PIC_BLOCKING_FLAGS` (0x1907), + // (0x1000) is a member of `WRITE_PIC_BLOCKING_FLAGS` (0x1987), // which every one of the four `hit` conjunctions requires CLEAR; and // it needs a pointer value, which is the case `pointer_tested` does // NOT skip. @@ -869,7 +874,8 @@ fn lower_put_value_static_write_ic( /// Registers arrive in k → v → t evaluation order (see the call site); from /// the target register onward the path is call-free until the store or the /// outlined slow call. Guards are byte-for-byte the static write PIC's -/// (GcHeader -8/-7/-6 with BLOCKING 0x1907 incl. typed-intact, ObjectHeader +/// (GcHeader -8/-7/-6 with BLOCKING 0x1987 incl. typed-intact and the packed +/// numeric-proof authority, ObjectHeader /// regular/class/token via the #6804 discriminated shape-token select). /// The raw store fires only for non-reference VALUE tags (not pointer/ /// string/bigint), so it needs no barrier and no layout note; every other diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 6b8a0bda62..8d3a1e08a5 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -21,11 +21,11 @@ const GC_TYPE_OBJECT: &str = "2"; // // gtype == GC_TYPE_OBJECT // flags & GC_FLAG_FORWARDED == 0 -// reserved & OBJ_FLAG_HAS_DESCRIPTORS == 0 +// reserved & (OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_PACKED_NUMERIC_PROOF) == 0 // -// Mask: 0x0800_0000 (descriptor bit) | 0x0000_8000 (forwarded bit) | -// 0x0000_00ff (the complete gtype byte). -const GC_OBJECT_METHOD_GUARD_MASK_I32: &str = "134250751"; // 0x0800_80ff +// Mask: 0x0800_0000 (descriptor bit) | 0x0080_0000 (packed proof bit) | +// 0x0000_8000 (forwarded bit) | 0x0000_00ff (the complete gtype byte). +const GC_OBJECT_METHOD_GUARD_MASK_I32: &str = "142639359"; // 0x0880_80ff const SHAPE_ID_BASE_NEG_I32: &str = "-2147483648"; // subtract 0x8000_0000 const SHAPE_ID_RANGE_LEN: &str = "1073741824"; // 0x4000_0000 @@ -42,7 +42,7 @@ const SHAPE_ID_RANGE_LEN: &str = "1073741824"; // 0x4000_0000 /// and its exact `(class_id, ShapeId)` pair still matches the /// compiler-published pair. Any failed proof takes the unchanged dynamic /// method fallback. -fn emit_inline_direct_method_shape_guard( +pub(crate) fn emit_inline_direct_method_shape_guard( ctx: &mut FnCtx<'_>, recv_box: &str, expected_class_id: &str, @@ -136,13 +136,15 @@ mod packed_guard_tests { let obj_type_mask = 0x0000_00ffu32; let forwarded = u32::from(0x80u8) << 8; let has_descriptors = 0x0800u32 << 16; - let mask = obj_type_mask | forwarded | has_descriptors; + let packed_numeric_proof = 0x0080u32 << 16; + let mask = obj_type_mask | forwarded | has_descriptors | packed_numeric_proof; let expected = u32::from(2u8); assert_eq!(GC_OBJECT_METHOD_GUARD_MASK_I32, mask.to_string()); assert_eq!(expected & mask, expected); assert_ne!((expected | forwarded) & mask, expected); assert_ne!((expected | has_descriptors) & mask, expected); + assert_ne!((expected | packed_numeric_proof) & mask, expected); assert_ne!((expected ^ 1) & mask, expected); } diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 81ef383ff4..31b57ee073 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -66,6 +66,7 @@ pub(crate) use func_ref::{ }; mod jsx; mod method_override; +pub(crate) use method_override::emit_inline_direct_method_shape_guard; mod namespace_call; mod native; mod native_module_dispatch; diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index 6f0d7a6e40..ed6316c519 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -1323,6 +1323,62 @@ pub(crate) fn try_lower_instance_method_call( crate::codegen::nonnegative_index_method_name(&fallback_fn, params) }) }); + // A versioned loop revalidated this exact `this.method` target + // and every array argument at the current iteration entry. + // Route directly to the private handle-ABI clone: unlike the + // ordinary `$idx_u31` body it contains no array fallback edge. + // The structural matcher admits no user code between that + // revalidation and these checked-reader calls. + let versioned_fact = ctx.versioned_indexed_loop_facts.last().cloned(); + if matches!(object, Expr::This) + && nonnegative_index_direct_name.is_some() + && versioned_fact.as_ref().is_some_and(|fact| { + fact.method.class_name == class_name && fact.method.method_name == property + }) + { + let fact = versioned_fact.expect("checked above"); + let params = ctx + .nonnegative_index_methods + .get(&typed_method_key) + .expect("versioned method remains indexed"); + let method = ctx + .classes + .get(&class_name) + .expect("versioned method class remains registered") + .methods + .iter() + .find(|method| method.name.as_str() == property) + .expect("versioned method remains registered"); + let array_params = + crate::codegen::nonnegative_index_fast_array_params(method, params); + let mut handle_storage = Vec::with_capacity(array_params.len()); + for array_param in array_params { + let position = method + .params + .iter() + .position(|param| param.id == array_param) + .expect("versioned array parameter remains registered"); + let Some(Expr::LocalGet(local_id)) = args.get(position) else { + handle_storage.clear(); + break; + }; + let Some(handle) = fact.live_array_handles.get(local_id) else { + handle_storage.clear(); + break; + }; + handle_storage.push(handle.clone()); + } + if !handle_storage.is_empty() { + let mut fast_args = arg_slices.clone(); + fast_args + .extend(handle_storage.iter().map(|handle| (I64, handle.as_str()))); + let target = crate::codegen::nonnegative_index_fast_array_method_name( + &fallback_fn, + params, + ); + return Ok(Some(ctx.block().call(DOUBLE, &target, &fast_args))); + } + } let typed_receiver_direct = match ( typed_receiver_direct_name.as_ref(), typed_receiver_info.as_ref(), diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 6653757bc3..d0b622dab4 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -698,6 +698,11 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { DOUBLE, &[DOUBLE, DOUBLE, PTR], ); + module.declare_function( + "js_packed_arraylike_loop_guard", + I32, + &[DOUBLE, DOUBLE, I32, PTR], + ); // Issue #957: tag-aware dynamic index write. Used by `Expr::IndexUpdate` // codegen to write back the incremented value without rebuilding the // IndexSet dispatch tree. Routes to `js_array_set_index_or_string` for diff --git a/crates/perry-codegen/src/stmt/if_stmt.rs b/crates/perry-codegen/src/stmt/if_stmt.rs index 8825774a8e..c8156dfc99 100644 --- a/crates/perry-codegen/src/stmt/if_stmt.rs +++ b/crates/perry-codegen/src/stmt/if_stmt.rs @@ -93,6 +93,14 @@ fn merge_native_arena_owner_aliases(ctx: &mut FnCtx<'_>, exits: &[NativeArenaOwn fn try_const_fold_condition(ctx: &FnCtx<'_>, condition: &perry_hir::Expr) -> Option { use perry_hir::{CompareOp, Expr, LogicalOp}; match condition { + Expr::LocalGet(id) + if ctx + .versioned_indexed_loop_facts + .last() + .is_some_and(|fact| fact.falsy_local_id == Some(*id)) => + { + Some(false) + } Expr::Compare { op, left, right } => { // Try to extract a known constant from one side and a literal // from the other. diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 4a2c62ad9f..1320580bb2 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1,12 +1,11 @@ -use super::*; - use super::let_buffer_views::{math_min_length_buffer_ids, register_noalias_buffer_view}; use super::let_stmt_facts::{ buffer_local_alias_source, collect_scalar_class_data, native_i32_alias_source, - note_ptr_shape_scalar_replaced, pod_view_count_source, record_pod_rejection, - record_scalar_aggregate_field, + note_ptr_shape_scalar_replaced, pod_view_count_source, record_array_length_snapshot, + record_pod_rejection, record_scalar_aggregate_field, }; use super::unused_expr::lower_unused_expr; +use super::*; use crate::expr::{ box_i1_for_compat_shadow, emit_root_nanbox_store_on_block, expr_produces_non_pointer_bits_by_construction, lower_expr_value, @@ -122,6 +121,7 @@ pub(crate) fn lower_let( } if let Some(init_expr) = init { crate::expr::record_local_value_alias_for_write(ctx, id, init_expr); + record_array_length_snapshot(ctx, id, init_expr); ctx.guarded_discriminant_aliases.remove(&id); if !mutable && !ctx.reassigned_locals.contains(&id) { if let perry_hir::Expr::PropertyGet { @@ -142,6 +142,7 @@ pub(crate) fn lower_let( } } else { ctx.local_value_aliases.remove(&id); + ctx.array_length_snapshots.remove(&id); ctx.guarded_discriminant_aliases.remove(&id); } crate::expr::record_int_facts_for_let(ctx, id, init, mutable); diff --git a/crates/perry-codegen/src/stmt/let_stmt_facts.rs b/crates/perry-codegen/src/stmt/let_stmt_facts.rs index 4494a1be44..1240445bb6 100644 --- a/crates/perry-codegen/src/stmt/let_stmt_facts.rs +++ b/crates/perry-codegen/src/stmt/let_stmt_facts.rs @@ -5,6 +5,25 @@ use super::*; +/// Remember an immutable `const n = receiver.length` association for guarded +/// counted-loop admission. The property read itself keeps ordinary semantics; +/// only a later runtime proof is allowed to consume this association. +pub(super) fn record_array_length_snapshot(ctx: &mut FnCtx<'_>, id: u32, init: &perry_hir::Expr) { + if ctx.reassigned_locals.contains(&id) { + return; + } + if let perry_hir::Expr::PropertyGet { + object, property, .. + } = init + { + if property == "length" { + if let perry_hir::Expr::LocalGet(array_id) = object.as_ref() { + ctx.array_length_snapshots.insert(id, *array_id); + } + } + } +} + use crate::native_value::BufferAccessMode; /// #8691: the aggregate scalar-replacement transform erases the carrier array diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 9495d6da49..a952e0a96e 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -4258,7 +4258,7 @@ fn local_array_element_type<'t>( /// an alloca (`ctx.locals`) this body does not have. Reading the flag without /// that distinction is what kept a captured `const rows: number[]` off the fast /// loop in a closure while the same code in a plain function got it. -fn packed_loop_array_binding_is_eligible(ctx: &FnCtx<'_>, arr_id: u32) -> bool { +pub(super) fn packed_loop_array_binding_is_eligible(ctx: &FnCtx<'_>, arr_id: u32) -> bool { packed_loop_array_binding_storage_is_addressable(ctx, arr_id) && !ctx.scalar_replaced_arrays.contains_key(&arr_id) && !ctx.native_facts.has_materialization_hazard(arr_id) @@ -4888,6 +4888,10 @@ pub(crate) fn lower_for( return Ok(()); } + if super::versioned_indexed_loop::lower(ctx, init, condition, update, body)? { + return Ok(()); + } + // #5093: monomorphic class-field hot loops (`counter.value = counter.value // + 1` after method inlining). Shape check hoisted to a preheader; fast // clone is call-free raw slot access. @@ -4904,6 +4908,14 @@ pub(crate) fn lower_for( return Ok(()); } + // #8690 owns only loops left over after the established packed-number, + // indexed-method, class-field, and homogeneous element-shape clones have + // had first refusal. Its runtime admission is deliberately broader, so + // trying it earlier would steal those specialized access shapes. + if super::stable_packed_loop::lower(ctx, init, condition, update, body)? { + return Ok(()); + } + lower_for_after_init(ctx, init, condition, update, body, "for") } @@ -4957,9 +4969,12 @@ pub(super) fn lower_for_after_init_with_i32_bound( // Saves ~25-30% on `for (let i = 0; i < arr.length; i++) arr[i] = i` // and `for (let i = 0; i < arr.length; i++) for (let j = 0; j < // arr.length; j++) ...` patterns. + // A precomputed bound replaces only the emitted length LOAD. Keep the + // structural classification: bounded-index facts, buffer-width facts and + // the counter's i32 slot are independent proofs consumed inside clones. let raw_hoist_classification: Option = condition.and_then(|cond| classify_for_length_hoist(ctx, cond, update, body)); - let hoist_rejection = if raw_hoist_classification.is_none() { + let hoist_rejection = if raw_hoist_classification.is_none() && precomputed_i32_bound.is_none() { condition.and_then(|cond| classify_for_length_hoist_rejection(ctx, cond, update, body)) } else { None @@ -5013,8 +5028,10 @@ pub(super) fn lower_for_after_init_with_i32_bound( // i32 counter slot below are proofs and storage, not emitted work, and the // clone's other lowering may depend on them; suppressing those too would // trade one silent loss for another. - let in_call_free_clone = - !ctx.element_shape_loop_facts.is_empty() || !ctx.class_field_loop_facts.is_empty(); + let in_call_free_clone = !ctx.element_shape_loop_facts.is_empty() + || !ctx.class_field_loop_facts.is_empty() + || !ctx.stable_packed_loop_facts.is_empty() + || precomputed_i32_bound.is_some(); let hoisted_length_slot: Option = if let Some(hoist) = hoist_classification { let hoisted_slot = if in_call_free_clone { None @@ -5115,7 +5132,7 @@ pub(super) fn lower_for_after_init_with_i32_bound( // `fcmp olt double`, letting LLVM's SCEV model `i` as a clean integer // induction variable. let local_bound_classification: Option<(u32, u32, perry_hir::CompareOp)> = - if hoist_classification.is_none() { + if hoist_classification.is_none() && precomputed_i32_bound.is_none() { condition.and_then(|cond| classify_for_local_bound(cond, update, body, ctx)) } else { None @@ -5178,6 +5195,7 @@ pub(super) fn lower_for_after_init_with_i32_bound( // safe and fall back to the generic comparison otherwise. let dynamic_i32_bound: Option = if hoist_classification.is_none() && local_bound_classification.is_none() + && precomputed_i32_bound.is_none() { condition .and_then(|cond| classify_for_local_bound_dynamic(cond, update, body, ctx)) @@ -5393,6 +5411,7 @@ pub(super) fn lower_for_after_init_with_i32_bound( // Body block. ctx.current_block = body_idx; + super::versioned_indexed_loop::emit_iteration_guard(ctx); if let Some(cond) = condition { let mut guarded = crate::expr::guarded_buffer_indices_for_condition(ctx, cond, loop_proof_scope_id); @@ -5580,9 +5599,9 @@ pub(crate) fn emit_gc_loop_safepoint( } // #7480 step 4: never inside a call-free-by-construction fast clone. // - // `lower_class_field_versioned_for` and `lower_element_shape_versioned_for` - // hoist a guard into a preheader and clone the body against it, and both - // rest on the SAME safety argument: the clone makes no call, therefore + // `lower_class_field_versioned_for`, `lower_element_shape_versioned_for`, + // and the stable-packed loop tier hoist a guard into a preheader and clone + // the body against it. All rest on the SAME safety argument: the clone makes no call, therefore // allocates nothing, therefore cannot collect, therefore the pointer the // preheader cached cannot move. Each verifies that by scanning its own // emitted blocks afterwards, and a clone whose call-freeness is unproven is @@ -5616,7 +5635,10 @@ pub(crate) fn emit_gc_loop_safepoint( // than to the generic diamond. Inside a fact scope, it can: the clone is // call-free or it is not entered, and the slow clone — lowered after the // scope is popped — keeps its poll either way. - if !ctx.element_shape_loop_facts.is_empty() || !ctx.class_field_loop_facts.is_empty() { + if !ctx.element_shape_loop_facts.is_empty() + || !ctx.class_field_loop_facts.is_empty() + || !ctx.stable_packed_loop_facts.is_empty() + { return; } // Only an ALLOCATING loop body can defer a collection to this poll; skip the diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index ebe0da2e38..49afe99e53 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -27,9 +27,11 @@ mod loops; mod masked_window_region; #[cfg(test)] mod prealloc_module_global_tests; +pub(crate) mod stable_packed_loop; mod switch_stmt; mod try_stmt; mod unused_expr; +mod versioned_indexed_loop; pub(crate) use if_stmt::lower_if; pub(crate) use let_stmt::lower_let; diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs new file mode 100644 index 0000000000..460a861385 --- /dev/null +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -0,0 +1,689 @@ +//! Guarded loop versions for counted Array and Array-subclass iteration. +//! +//! A one-time runtime admission publishes scalar layout facts. The fast copy +//! is entered only after its emitted blocks are proven call-free, so its +//! preheader-cached receiver and storage bases stay valid for the whole copy. +//! Failed admission runs the unchanged generic loop from the current counter. + +use anyhow::Result; +use perry_hir::{CompareOp, Expr, Stmt, UpdateOp}; + +use crate::expr::{FnCtx, StablePackedLoopFact, StablePackedNumericAccess}; +use crate::native_value::{BoundsState, BufferAccessMode, LoweredValue, MaterializationReason}; +use crate::types::{DOUBLE, I1, I32, I64, PTR}; + +#[derive(Clone, Copy)] +enum LoopBound { + Snapshot(u32), + LiveLength, +} + +struct Candidate { + counter_id: u32, + array_id: u32, + bound: LoopBound, + numeric_elements: bool, +} + +fn target_below_numeric_operator( + expr: &Expr, + array_id: u32, + counter_id: u32, + numeric_context: bool, +) -> bool { + if matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) { + return numeric_context; + } + if matches!(expr, Expr::Closure { .. }) { + return false; + } + let child_numeric_context = + numeric_context || matches!(expr, Expr::Binary { .. } | Expr::NumberCoerce(_)); + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found + && target_below_numeric_operator(child, array_id, counter_id, child_numeric_context) + { + found = true; + } + }); + found +} + +fn leading_read_requires_numeric(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { + let Some(first) = body.first() else { + return false; + }; + let expr = match first { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Throw(expr) + | Stmt::Return(Some(expr)) => expr, + _ => return false, + }; + target_below_numeric_operator(expr, array_id, counter_id, false) +} + +fn expr_flags(expr: &Expr, array_id: u32, counter_id: u32, target: &mut bool, call: &mut bool) { + if matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) { + *target = true; + } + if matches!(expr, Expr::Call { .. } | Expr::New { .. }) { + *call = true; + } + if !matches!(expr, Expr::Closure { .. }) { + perry_hir::walker::walk_expr_children(expr, &mut |child| { + expr_flags(child, array_id, counter_id, target, call); + }); + } +} + +fn stmt_flags(stmt: &Stmt, array_id: u32, counter_id: u32) -> (bool, bool) { + let mut target = false; + let mut call = false; + match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Throw(expr) + | Stmt::Return(Some(expr)) => { + expr_flags(expr, array_id, counter_id, &mut target, &mut call); + } + _ => {} + } + (target, call) +} + +/// The direct read must be in the first straight-line statement and before any +/// explicit user call. Later statements may allocate or invoke callbacks: the +/// next iteration reloads the root and validates before using it again. +fn body_has_safe_leading_read(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { + let Some(first) = body.first() else { + return false; + }; + let (first_target, first_call) = stmt_flags(first, array_id, counter_id); + if !first_target || first_call { + return false; + } + !body[1..] + .iter() + .any(|stmt| stmt_flags(stmt, array_id, counter_id).0) +} + +fn stmt_contains_break(stmt: &Stmt) -> bool { + match stmt { + Stmt::Break | Stmt::LabeledBreak(_) => true, + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().any(stmt_contains_break) + || else_branch + .as_ref() + .is_some_and(|branch| branch.iter().any(stmt_contains_break)) + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + body.iter().any(stmt_contains_break) + } + Stmt::For { init, body, .. } => { + init.as_deref().is_some_and(stmt_contains_break) || body.iter().any(stmt_contains_break) + } + Stmt::Labeled { body, .. } => stmt_contains_break(body), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().any(stmt_contains_break) + || catch + .as_ref() + .is_some_and(|clause| clause.body.iter().any(stmt_contains_break)) + || finally + .as_ref() + .is_some_and(|body| body.iter().any(stmt_contains_break)) + } + Stmt::Switch { cases, .. } => cases + .iter() + .any(|case| case.body.iter().any(stmt_contains_break)), + _ => false, + } +} + +fn match_candidate( + ctx: &FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], +) -> Option { + if !ctx.pending_labels.is_empty() { + return None; + } + let counter_id = match init? { + Stmt::Let { + id, + init: Some(Expr::Integer(0)), + .. + } => *id, + _ => return None, + }; + if !matches!( + update, + Some(Expr::Update { + id, + op: UpdateOp::Increment, + .. + }) if *id == counter_id + ) { + return None; + } + let right = match condition? { + Expr::Compare { + op: CompareOp::Lt, + left, + right, + } if matches!(left.as_ref(), Expr::LocalGet(id) if *id == counter_id) => right.as_ref(), + _ => return None, + }; + let (array_id, bound) = match right { + Expr::LocalGet(bound_id) => { + let array_id = *ctx.array_length_snapshots.get(bound_id)?; + if ctx.reassigned_locals.contains(bound_id) { + return None; + } + (array_id, LoopBound::Snapshot(*bound_id)) + } + Expr::PropertyGet { + object, property, .. + } if property == "length" => match object.as_ref() { + Expr::LocalGet(array_id) => (*array_id, LoopBound::LiveLength), + _ => return None, + }, + _ => return None, + }; + let receiver = Expr::LocalGet(array_id); + if ctx.reassigned_locals.contains(&array_id) + || ctx.closure_captures.contains_key(&array_id) + || (ctx.locals.contains_key(&array_id) && ctx.boxed_vars.contains(&array_id)) + || (!ctx.locals.contains_key(&array_id) && !ctx.module_globals.contains_key(&array_id)) + // TypedArrays have their own element-width-aware indexed lowering. + // Even though the runtime guard would decline their non-Array header, + // emitting the speculative clone can feed its numeric facts into + // function-wide native-representation selection. In particular a + // Uint32Array XOR then lost the required signed i32 canonicalization + // in the generic copy. Known TypedArrays are never valid candidates, + // so reject them before cloning rather than relying on the guard. + || crate::type_analysis::is_typed_array_expr(ctx, &receiver) + || super::loops::stmts_mutate_local(body, counter_id) + // A fast-loop `break` reaches that clone's exit block. Live-length + // versions use the same block to enter the generic continuation, so + // replaying the current iteration would duplicate preceding effects. + || body.iter().any(stmt_contains_break) + || !body_has_safe_leading_read(body, array_id, counter_id) + // Preserve the existing escape/materialization contract. A dynamic + // call before the loop may have exposed the binding to arbitrary JS; + // the broad #8690 guard must not resurrect a proof deliberately + // retired by that analysis. + || !super::loops::packed_loop_array_binding_is_eligible(ctx, array_id) + { + return None; + } + Some(Candidate { + counter_id, + array_id, + bound, + numeric_elements: leading_read_requires_numeric(body, array_id, counter_id), + }) +} + +fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String { + let ptr = ctx + .block() + .gep(I64, descriptor, &[(I64, &index.to_string())]); + ctx.block().load(I64, &ptr) +} + +fn record_artifacts(ctx: &mut FnCtx<'_>, array_id: u32, receiver: &str) { + let lowered = LoweredValue::js_value(receiver.to_string()); + ctx.record_lowered_value_with_access_mode_and_facts( + "StablePackedArraylikeLoop", + Some(array_id), + "stable_packed_arraylike_preheader", + &lowered, + Some(BoundsState::Guarded { + guard_id: "packed_arraylike_loop_guard".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + Vec::new(), + Vec::new(), + false, + false, + vec![ + "loop_versioning=stable_packed_arraylike".to_string(), + "proof=preheader_scalar_layout".to_string(), + "revalidation=none_call_free_clone".to_string(), + "side_exit=current_index".to_string(), + ], + ); + ctx.record_lowered_value_with_access_mode_and_facts( + "StablePackedArraylikeLoop", + Some(array_id), + "stable_packed_arraylike_generic_side_exit", + &lowered, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + None, + None, + Vec::new(), + Vec::new(), + false, + false, + vec![ + "loop_versioning=stable_packed_arraylike_fallback".to_string(), + "resume=current_index".to_string(), + ], + ); +} + +pub(crate) fn try_lower_index_get( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Option { + let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object, index) else { + return None; + }; + let fact = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? + .clone(); + let raw = fact.live_receiver_handle?; + let counter_slot = ctx.i32_counter_slots.get(counter_id)?.clone(); + let idx_i32 = ctx.block().load(I32, &counter_slot); + let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); + if let Some(access) = fact.numeric_access { + let byte_offset = ctx.block().shl(I64, &idx_i64, "3"); + let plain_addr = ctx.block().add(I64, &access.plain_base, &byte_offset); + let inline_addr = ctx + .block() + .add(I64, &access.object_inline_base, &byte_offset); + let spill_addr = ctx + .block() + .add(I64, &access.object_spill_base, &byte_offset); + let is_inline = ctx + .block() + .icmp_ult(I64, &idx_i64, &access.object_inline_count); + let object_addr = ctx + .block() + .select(I1, &is_inline, I64, &inline_addr, &spill_addr); + let element_addr = ctx + .block() + .select(I1, &access.is_plain, I64, &plain_addr, &object_addr); + let element_ptr = ctx.block().inttoptr(I64, &element_addr); + return Some(ctx.block().load(DOUBLE, &element_ptr)); + } + let kind = descriptor_word(ctx, &fact.descriptor, 0); + + let plain_idx = ctx.new_block("stable_packed.load.plain"); + let object_idx = ctx.new_block("stable_packed.load.object"); + let object_inline_idx = ctx.new_block("stable_packed.load.object.inline"); + let object_spill_idx = ctx.new_block("stable_packed.load.object.spill"); + let object_spill_ptr_idx = ctx.new_block("stable_packed.load.object.spill_ptr"); + let merge_idx = ctx.new_block("stable_packed.load.merge"); + let plain_label = ctx.block_label(plain_idx); + let object_label = ctx.block_label(object_idx); + let object_inline_label = ctx.block_label(object_inline_idx); + let object_spill_label = ctx.block_label(object_spill_idx); + let object_spill_ptr_label = ctx.block_label(object_spill_ptr_idx); + let merge_label = ctx.block_label(merge_idx); + let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); + ctx.block().cond_br(&is_plain, &plain_label, &object_label); + + ctx.current_block = plain_idx; + let byte_offset = ctx.block().shl(I64, &idx_i64, "3"); + let with_header = ctx.block().add(I64, &byte_offset, "8"); + let element_addr = ctx.block().add(I64, &raw, &with_header); + let element_ptr = ctx.block().inttoptr(I64, &element_addr); + let plain_raw = ctx.block().load(DOUBLE, &element_ptr); + let plain_bits = ctx.block().bitcast_double_to_i64(&plain_raw); + let is_hole = ctx + .block() + .icmp_eq(I64, &plain_bits, crate::nanbox::TAG_HOLE_I64); + let undefined = ctx + .block() + .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64); + let plain_value = ctx + .block() + .select(I1, &is_hole, DOUBLE, &undefined, &plain_raw); + let plain_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = object_idx; + let element_base = descriptor_word(ctx, &fact.descriptor, 4); + let packed_bounds = descriptor_word(ctx, &fact.descriptor, 5); + let inline_bound = ctx.block().lshr(I64, &packed_bounds, "32"); + let slot = ctx.block().add(I64, &element_base, &idx_i64); + let inline = ctx.block().icmp_ult(I64, &slot, &inline_bound); + ctx.block() + .cond_br(&inline, &object_inline_label, &object_spill_label); + + ctx.current_block = object_inline_idx; + let object_header_size = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let slot_bytes = ctx.block().shl(I64, &slot, "3"); + let slot_offset = ctx.block().add(I64, &slot_bytes, &object_header_size); + let slot_addr = ctx.block().add(I64, &raw, &slot_offset); + let slot_ptr = ctx.block().inttoptr(I64, &slot_addr); + let inline_value = ctx.block().load(DOUBLE, &slot_ptr); + let inline_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = object_spill_idx; + let pointer_size = if crate::target_layout::target_is_ilp32(ctx.target_triple) { + 4 + } else { + 8 + }; + let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) + - pointer_size) + .to_string(); + let meta_addr = ctx.block().add(I64, &raw, &meta_offset); + let meta_slot = ctx.block().inttoptr(I64, &meta_addr); + let meta_native = ctx + .block() + .load(if pointer_size == 4 { I32 } else { I64 }, &meta_slot); + let meta = if pointer_size == 4 { + ctx.block().zext(I32, &meta_native, I64) + } else { + meta_native + }; + let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); + ctx.block() + .cond_br(&has_meta, &object_spill_ptr_label, &fact.side_exit_label); + + ctx.current_block = object_spill_ptr_idx; + let meta_ptr = ctx.block().inttoptr(I64, &meta); + let spill_slot = ctx.block().gep(I64, &meta_ptr, &[(I64, "4")]); + let spill = ctx.block().load(I64, &spill_slot); + let has_spill = ctx.block().icmp_ne(I64, &spill, "0"); + let spill_deref_idx = ctx.new_block("stable_packed.load.object.spill_deref"); + let spill_deref_label = ctx.block_label(spill_deref_idx); + ctx.block() + .cond_br(&has_spill, &spill_deref_label, &fact.side_exit_label); + + ctx.current_block = spill_deref_idx; + let spill_ptr = ctx.block().inttoptr(I64, &spill); + let spill_len = ctx.block().load(I32, &spill_ptr); + let spill_len64 = ctx.block().zext(I32, &spill_len, I64); + let in_bounds = ctx.block().icmp_ult(I64, &slot, &spill_len64); + let spill_load_idx = ctx.new_block("stable_packed.load.object.spill_load"); + let spill_load_label = ctx.block_label(spill_load_idx); + ctx.block() + .cond_br(&in_bounds, &spill_load_label, &fact.side_exit_label); + + ctx.current_block = spill_load_idx; + let spill_word = ctx.block().add(I64, &slot, "1"); + let spill_element = ctx + .block() + .gep_inbounds(I64, &spill_ptr, &[(I64, &spill_word)]); + let spill_value = ctx.block().load(DOUBLE, &spill_element); + let spill_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + Some(ctx.block().phi( + DOUBLE, + &[ + (&plain_value, &plain_end), + (&inline_value, &inline_end), + (&spill_value, &spill_end), + ], + )) +} + +pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let Expr::IndexGet { object, index } = expr else { + return false; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object.as_ref(), index.as_ref()) + else { + return false; + }; + ctx.stable_packed_loop_facts.iter().rev().any(|fact| { + fact.numeric_elements + && fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + }) +} + +pub(super) fn lower( + ctx: &mut FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], +) -> Result { + let Some(candidate) = match_candidate(ctx, init, condition, update, body) else { + return Ok(false); + }; + let inserted_counter = if ctx.i32_counter_slots.contains_key(&candidate.counter_id) { + false + } else { + let Some(counter_slot) = ctx.locals.get(&candidate.counter_id).cloned() else { + return Ok(false); + }; + let slot = ctx.func.alloca_entry(I32); + let value = ctx.block().load(DOUBLE, &counter_slot); + let i32_value = ctx.block().fptosi(DOUBLE, &value, I32); + ctx.block().store(I32, &i32_value, &slot); + ctx.i32_counter_slots.insert(candidate.counter_id, slot); + true + }; + + let receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(candidate.array_id))?; + let bound_box = match candidate.bound { + LoopBound::Snapshot(bound_id) => crate::expr::lower_expr(ctx, &Expr::LocalGet(bound_id))?, + LoopBound::LiveLength => "-1.0".to_string(), + }; + let descriptor = ctx.func.alloca_entry_array(I64, 7); + let guard = ctx.block().call( + I32, + "js_packed_arraylike_loop_guard", + &[ + (DOUBLE, &receiver), + (DOUBLE, &bound_box), + (I32, if candidate.numeric_elements { "1" } else { "0" }), + (PTR, &descriptor), + ], + ); + let admitted = ctx.block().icmp_ne(I32, &guard, "0"); + // Deliberately left unterminated until the emitted fast clone has been + // scanned. The cached receiver below is safe only when no runtime call can + // allocate, collect, or revoke an admitted layout while that clone runs. + let admission_idx = ctx.current_block; + + let fast_pre_idx = ctx.new_block("stable_packed.loop.fast.preheader"); + let slow_pre_idx = ctx.new_block("stable_packed.loop.slow.preheader"); + let merge_idx = ctx.new_block("stable_packed.loop.merge"); + let fast_pre_label = ctx.block_label(fast_pre_idx); + let slow_pre_label = ctx.block_label(slow_pre_idx); + let merge_label = ctx.block_label(merge_idx); + + let bound64 = { + ctx.current_block = fast_pre_idx; + descriptor_word(ctx, &descriptor, 6) + }; + let bound_i32 = ctx.block().trunc(I64, &bound64, I32); + // Reload after the runtime admission call. Once the clone scan succeeds, + // this root cannot move until the clone returns because the clone contains + // no GC-unsafe call or allocation point. + let fast_receiver = crate::expr::lower_expr(ctx, &Expr::LocalGet(candidate.array_id))?; + let fast_bits = ctx.block().bitcast_double_to_i64(&fast_receiver); + let fast_raw = ctx + .block() + .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64); + let fast_scan_start = ctx.func.num_blocks(); + let numeric_access = if candidate.numeric_elements { + let kind = descriptor_word(ctx, &descriptor, 0); + let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); + let plain_base = ctx.block().add(I64, &fast_raw, "8"); + + let element_base = descriptor_word(ctx, &descriptor, 4); + let packed_bounds = descriptor_word(ctx, &descriptor, 5); + let inline_bound = ctx.block().lshr(I64, &packed_bounds, "32"); + let has_inline = ctx.block().icmp_ult(I64, &element_base, &inline_bound); + let inline_span = ctx.block().sub(I64, &inline_bound, &element_base); + let object_inline_count = ctx.block().select(I1, &has_inline, I64, &inline_span, "0"); + let element_bytes = ctx.block().shl(I64, &element_base, "3"); + let object_header_size = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let inline_offset = ctx.block().add(I64, &object_header_size, &element_bytes); + let object_inline_base = ctx.block().add(I64, &fast_raw, &inline_offset); + + // Only Array-subclass objects own ObjectMeta. Keep the metadata load + // control-dependent so a plain Array never interprets element bits as + // a pointer. A missing spill is valid when the admitted bound fits in + // inline storage; the selected fallback address is then never loaded. + let plain_setup_idx = ctx.new_block("stable_packed.setup.plain"); + let object_setup_idx = ctx.new_block("stable_packed.setup.object"); + let meta_setup_idx = ctx.new_block("stable_packed.setup.meta"); + let setup_merge_idx = ctx.new_block("stable_packed.setup.merge"); + let plain_setup_label = ctx.block_label(plain_setup_idx); + let object_setup_label = ctx.block_label(object_setup_idx); + let meta_setup_label = ctx.block_label(meta_setup_idx); + let setup_merge_label = ctx.block_label(setup_merge_idx); + ctx.block() + .cond_br(&is_plain, &plain_setup_label, &object_setup_label); + + ctx.current_block = plain_setup_idx; + ctx.block().br(&setup_merge_label); + + ctx.current_block = object_setup_idx; + let pointer_size = if crate::target_layout::target_is_ilp32(ctx.target_triple) { + 4 + } else { + 8 + }; + let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple) + - pointer_size) + .to_string(); + let meta_addr = ctx.block().add(I64, &fast_raw, &meta_offset); + let meta_slot = ctx.block().inttoptr(I64, &meta_addr); + let meta_native = ctx + .block() + .load(if pointer_size == 4 { I32 } else { I64 }, &meta_slot); + let meta = if pointer_size == 4 { + ctx.block().zext(I32, &meta_native, I64) + } else { + meta_native + }; + let has_meta = ctx.block().icmp_ne(I64, &meta, "0"); + ctx.block() + .cond_br(&has_meta, &meta_setup_label, &setup_merge_label); + + ctx.current_block = meta_setup_idx; + let meta_ptr = ctx.block().inttoptr(I64, &meta); + let spill_slot = ctx.block().gep(I64, &meta_ptr, &[(I64, "4")]); + let spill = ctx.block().load(I64, &spill_slot); + ctx.block().br(&setup_merge_label); + + ctx.current_block = setup_merge_idx; + let spill = ctx.block().phi( + I64, + &[ + ("0", &plain_setup_label), + ("0", &object_setup_label), + (&spill, &meta_setup_label), + ], + ); + let has_spill = ctx.block().icmp_ne(I64, &spill, "0"); + let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, &fast_raw); + let spill_offset = ctx.block().add(I64, &element_bytes, "8"); + let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); + Some(StablePackedNumericAccess { + is_plain, + plain_base, + object_inline_count, + object_inline_base, + object_spill_base, + }) + } else { + None + }; + ctx.stable_packed_loop_facts.push(StablePackedLoopFact { + counter_local_id: candidate.counter_id, + array_local_id: candidate.array_id, + side_exit_label: slow_pre_label.clone(), + descriptor, + live_receiver_handle: Some(fast_raw), + numeric_elements: candidate.numeric_elements, + numeric_access, + }); + super::loops::lower_for_after_init_with_i32_bound( + ctx, + init, + condition, + update, + body, + "for.stable_packed_fast", + Some((candidate.counter_id, bound_i32)), + )?; + ctx.stable_packed_loop_facts.pop(); + if !ctx.block().is_terminated() { + // A call-free clone cannot grow or shrink its receiver, so exhausting + // the admitted bound is also the exact live-length loop exit. + ctx.block().br(&merge_label); + } + let fast_scan_end = ctx.func.num_blocks(); + let fast_clone_call_free = !ctx.func.blocks()[fast_pre_idx].contains_gc_unsafe_call() + && (fast_scan_start..fast_scan_end) + .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); + ctx.current_block = admission_idx; + if fast_clone_call_free { + record_artifacts(ctx, candidate.array_id, &receiver); + ctx.block() + .cond_br(&admitted, &fast_pre_label, &slow_pre_label); + } else { + ctx.block().br(&slow_pre_label); + } + + ctx.current_block = slow_pre_idx; + super::loops::lower_for_after_init( + ctx, + init, + condition, + update, + body, + "for.stable_packed_slow", + )?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + ctx.current_block = merge_idx; + if inserted_counter { + ctx.i32_counter_slots.remove(&candidate.counter_id); + } + Ok(true) +} diff --git a/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs b/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs new file mode 100644 index 0000000000..4332a291ab --- /dev/null +++ b/crates/perry-codegen/src/stmt/versioned_indexed_loop.rs @@ -0,0 +1,549 @@ +//! Fallback-free loop version for checked indexed-reader callbacks. +//! +//! This recognizes a structural family rather than source names: a canonical +//! zero-based loop loads one entity from an array, optionally skips through an +//! immutable filter, evaluates one or more checked indexed-reader methods, and +//! invokes arbitrary callback code last. A preheader admits exact arrays and +//! the direct method target. Each fast iteration revalidates compact scalar +//! fingerprints before any effect; failure resumes the unchanged generic loop +//! at the current counter. + +use std::collections::{BTreeSet, HashMap}; + +use anyhow::Result; +use perry_hir::{CompareOp, Expr, LogicalOp, Stmt, UpdateOp}; + +use crate::expr::{ + FnCtx, VersionedIndexedArrayFact, VersionedIndexedLoopFact, VersionedIndexedMethodFact, +}; +use crate::types::{DOUBLE, I1, I128, I16, I32, I64, I8}; + +#[derive(Clone)] +struct Candidate { + counter_id: u32, + bound_id: u32, + filter_id: Option, + arrays: Vec, + class_name: String, + method_name: String, +} + +fn checked_reader_call( + ctx: &FnCtx<'_>, + expr: &Expr, + counter_id: u32, +) -> Option<(String, String, Vec)> { + let Expr::Call { callee, args, .. } = expr else { + return None; + }; + let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + else { + return None; + }; + if !matches!(object.as_ref(), Expr::This) { + return None; + } + let class_name = ctx.class_stack.last()?.clone(); + let key = (class_name.clone(), property.clone()); + let index_params = ctx.nonnegative_index_methods.get(&key)?; + let method = ctx + .classes + .get(&class_name)? + .methods + .iter() + .find(|method| method.name == *property)?; + if args.len() != method.params.len() + || !index_params.iter().all(|id| { + method + .params + .iter() + .position(|param| param.id == *id) + .and_then(|position| args.get(position)) + .is_some_and(|arg| matches!(arg, Expr::LocalGet(id) if *id == counter_id)) + }) + { + return None; + } + let array_params = crate::codegen::nonnegative_index_fast_array_params(method, index_params); + if array_params.is_empty() { + return None; + } + let mut arrays = Vec::with_capacity(array_params.len()); + for array_param in array_params { + let position = method + .params + .iter() + .position(|param| param.id == array_param)?; + let Expr::LocalGet(local_id) = args.get(position)? else { + return None; + }; + arrays.push(*local_id); + } + Some((class_name, property.clone(), arrays)) +} + +fn match_candidate( + ctx: &FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], +) -> Option { + if !ctx.pending_labels.is_empty() { + return None; + } + let counter_id = match init? { + Stmt::Let { + id, + init: Some(Expr::Integer(0)), + .. + } => *id, + _ => return None, + }; + let bound_id = match condition? { + Expr::Compare { + op: CompareOp::Lt, + left, + right, + } if matches!(left.as_ref(), Expr::LocalGet(id) if *id == counter_id) => { + match right.as_ref() { + Expr::LocalGet(id) => *id, + _ => return None, + } + } + _ => return None, + }; + if !matches!( + update, + Some(Expr::Update { + id, + op: UpdateOp::Increment, + .. + }) if *id == counter_id + ) || ctx.boxed_vars.contains(&counter_id) + || ctx.closure_captures.contains_key(&counter_id) + || super::loops::stmts_mutate_local(body, counter_id) + || ctx.reassigned_locals.contains(&bound_id) + || ctx.boxed_vars.contains(&bound_id) + || !ctx.locals.contains_key(&bound_id) + { + return None; + } + + let (entity_stmt, filter_stmt, callback_stmt) = match body { + [entity, filter, callback] => (entity, Some(filter), callback), + [entity, callback] => (entity, None, callback), + _ => return None, + }; + let (entity_id, entity_array_id) = match entity_stmt { + Stmt::Let { + id, + init: Some(Expr::IndexGet { object, index }), + .. + } if matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) => { + match object.as_ref() { + Expr::LocalGet(array_id) => (*id, *array_id), + _ => return None, + } + } + _ => return None, + }; + let filter_id = match filter_stmt { + Some(Stmt::If { + condition: + Expr::Logical { + op: LogicalOp::And, + left, + .. + }, + then_branch, + else_branch: None, + }) if matches!(then_branch.as_slice(), [Stmt::Continue]) => match left.as_ref() { + Expr::LocalGet(id) if !ctx.boxed_vars.contains(id) && ctx.locals.contains_key(id) => { + Some(*id) + } + _ => return None, + }, + Some(_) => return None, + None => None, + }; + + let Expr::Call { + callee: callback, + args: callback_args, + .. + } = (match callback_stmt { + Stmt::Expr(expr) => expr, + _ => return None, + }) + else { + return None; + }; + let callback_id = match callback.as_ref() { + Expr::LocalGet(id) => *id, + _ => return None, + }; + if ctx.reassigned_locals.contains(&callback_id) + || ctx.boxed_vars.contains(&callback_id) + || !matches!(callback_args.first(), Some(Expr::LocalGet(id)) if *id == entity_id) + || callback_args.len() < 2 + { + return None; + } + + let mut arrays = BTreeSet::from([entity_array_id]); + let mut selected_method: Option<(String, String)> = None; + for arg in &callback_args[1..] { + let (class_name, method_name, method_arrays) = checked_reader_call(ctx, arg, counter_id)?; + if selected_method + .as_ref() + .is_some_and(|selected| selected != &(class_name.clone(), method_name.clone())) + { + return None; + } + selected_method = Some((class_name, method_name)); + arrays.extend(method_arrays); + } + let (class_name, method_name) = selected_method?; + + // Every retained pointer is an immutable plain local with an exact shadow + // root. The callback cannot rebind these lexical slots; moving GC rewrites + // them, and the next iteration reloads the rewritten box before dereference. + if arrays.iter().any(|id| { + ctx.reassigned_locals.contains(id) + || ctx.boxed_vars.contains(id) + || ctx.closure_captures.contains_key(id) + || ctx.module_globals.contains_key(id) + || !ctx.locals.contains_key(id) + || !ctx.shadow_slot_map.contains_key(id) + }) { + return None; + } + let this_slot = ctx.this_stack.last()?; + if this_slot.is_empty() { + return None; + } + + Some(Candidate { + counter_id, + bound_id, + filter_id, + arrays: arrays.into_iter().collect(), + class_name, + method_name, + }) +} + +fn emit_array_admission( + ctx: &mut FnCtx<'_>, + local_id: u32, + bound_i32: &str, + success_label: &str, + slow_label: &str, +) -> Option<(String, String)> { + let local_slot = ctx.locals.get(&local_id)?.clone(); + let deref_idx = ctx.new_block("versioned_index.array.deref"); + let deref_label = ctx.block_label(deref_idx); + let heap_floor = + crate::target_layout::heap_addr_lower_bound_inclusive(ctx.target_triple).to_string(); + let heap_ceiling = + crate::target_layout::heap_addr_upper_bound_exclusive(ctx.target_triple).to_string(); + + let array_box = ctx.block().load(DOUBLE, &local_slot); + let array_bits = ctx.block().bitcast_double_to_i64(&array_box); + let array_handle = ctx + .block() + .and(I64, &array_bits, crate::nanbox::POINTER_MASK_I64); + let tag = ctx.block().lshr(I64, &array_bits, "48"); + let is_pointer = ctx.block().icmp_eq(I64, &tag, "32765"); + let above_floor = ctx.block().icmp_uge(I64, &array_handle, &heap_floor); + let below_ceiling = ctx.block().icmp_ult(I64, &array_handle, &heap_ceiling); + let in_heap = ctx.block().and(I1, &above_floor, &below_ceiling); + let safe = ctx.block().and(I1, &is_pointer, &in_heap); + ctx.block().cond_br(&safe, &deref_label, slow_label); + + ctx.current_block = deref_idx; + let fingerprint_addr = ctx.block().sub(I64, &array_handle, "8"); + let fingerprint_ptr = ctx.block().inttoptr(I64, &fingerprint_addr); + let fingerprint = ctx.block().load_aligned(I128, &fingerprint_ptr, 8); + let gc_header = ctx.block().trunc(I128, &fingerprint, I64); + let array_header = ctx.block().lshr(I128, &fingerprint, "64"); + let gc_type = ctx.block().trunc(I64, &gc_header, I8); + let is_array = ctx.block().icmp_eq(I8, &gc_type, "1"); + let flags_shifted = ctx.block().lshr(I64, &gc_header, "8"); + let flags = ctx.block().trunc(I64, &flags_shifted, I8); + let forwarded = ctx.block().and(I8, &flags, "128"); + let not_forwarded = ctx.block().icmp_eq(I8, &forwarded, "0"); + let reserved_shifted = ctx.block().lshr(I64, &gc_header, "16"); + let reserved = ctx.block().trunc(I64, &reserved_shifted, I16); + let descriptors = ctx.block().and(I16, &reserved, "1024"); + let no_descriptors = ctx.block().icmp_eq(I16, &descriptors, "0"); + let prototype_invalidated = ctx + .block() + .load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); + let prototype_ok = ctx.block().icmp_eq(I8, &prototype_invalidated, "0"); + let length = ctx.block().trunc(I128, &array_header, I32); + let capacity_shifted = ctx.block().lshr(I128, &array_header, "32"); + let capacity = ctx.block().trunc(I128, &capacity_shifted, I32); + let bound_fits = ctx.block().icmp_ule(I32, bound_i32, &length); + let length_sane = ctx.block().icmp_ule(I32, &length, "16000000"); + let capacity_sane = ctx.block().icmp_ule(I32, &capacity, "16000000"); + let length_within_capacity = ctx.block().icmp_ule(I32, &length, &capacity); + let mut pass = ctx.block().and(I1, &is_array, ¬_forwarded); + pass = ctx.block().and(I1, &pass, &no_descriptors); + pass = ctx.block().and(I1, &pass, &prototype_ok); + pass = ctx.block().and(I1, &pass, &bound_fits); + pass = ctx.block().and(I1, &pass, &length_sane); + pass = ctx.block().and(I1, &pass, &capacity_sane); + pass = ctx.block().and(I1, &pass, &length_within_capacity); + ctx.block().cond_br(&pass, success_label, slow_label); + Some((local_slot, fingerprint)) +} + +/// Emit the compact per-iteration check and publish fresh live handles for the +/// fallback-free body. Returns true when a fact was consumed. +pub(super) fn emit_iteration_guard(ctx: &mut FnCtx<'_>) -> bool { + let Some(fact) = ctx.versioned_indexed_loop_facts.last().cloned() else { + return false; + }; + let continue_idx = ctx.new_block("versioned_index.iteration.fast"); + let continue_label = ctx.block_label(continue_idx); + let array_invalidated = ctx + .block() + .load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); + let mut pass = ctx.block().icmp_eq(I8, &array_invalidated, "0"); + let mut live_handles = HashMap::new(); + + for array in &fact.arrays { + let array_box = ctx.block().load(DOUBLE, &array.local_slot); + let array_bits = ctx.block().bitcast_double_to_i64(&array_box); + let array_handle = ctx + .block() + .and(I64, &array_bits, crate::nanbox::POINTER_MASK_I64); + let fingerprint_addr = ctx.block().sub(I64, &array_handle, "8"); + let fingerprint_ptr = ctx.block().inttoptr(I64, &fingerprint_addr); + let current = ctx.block().load_aligned(I128, &fingerprint_ptr, 8); + let unchanged = ctx + .block() + .icmp_eq(I128, ¤t, &array.expected_fingerprint); + pass = ctx.block().and(I1, &pass, &unchanged); + live_handles.insert(array.local_id, array_handle); + } + + let all_invalidated = + ctx.block() + .load_atomic_acquire(I8, "@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED", 1); + let all_methods_ok = ctx.block().icmp_eq(I8, &all_invalidated, "0"); + let method_slot_ptr = ctx.block().gep( + I8, + "@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD", + &[(I64, &fact.method.method_guard_slot)], + ); + let method_invalidated = ctx.block().load_atomic_acquire(I8, &method_slot_ptr, 1); + let method_ok = ctx.block().icmp_eq(I8, &method_invalidated, "0"); + let this_box = ctx.block().load(DOUBLE, &fact.method.this_slot); + let this_bits = ctx.block().bitcast_double_to_i64(&this_box); + let this_handle = ctx + .block() + .and(I64, &this_bits, crate::nanbox::POINTER_MASK_I64); + let object_ptr = ctx.block().inttoptr(I64, &this_handle); + let gc_header_ptr = ctx.block().gep(I8, &object_ptr, &[(I64, "-8")]); + let gc_header = ctx.block().load(I32, &gc_header_ptr); + let guarded_gc_bits = ctx.block().and(I32, &gc_header, "142639359"); + let gc_ok = ctx.block().icmp_eq(I32, &guarded_gc_bits, "2"); + let class_shape = ctx.block().load(I64, &object_ptr); + let expected_shape_i64 = ctx.block().zext(I32, &fact.method.expected_shape_id, I64); + let expected_shape_high = ctx.block().shl(I64, &expected_shape_i64, "32"); + let expected_class_shape = + ctx.block() + .or(I64, &expected_shape_high, &fact.method.expected_class_id); + let class_shape_ok = ctx + .block() + .icmp_eq(I64, &class_shape, &expected_class_shape); + pass = ctx.block().and(I1, &pass, &all_methods_ok); + pass = ctx.block().and(I1, &pass, &method_ok); + pass = ctx.block().and(I1, &pass, &gc_ok); + pass = ctx.block().and(I1, &pass, &class_shape_ok); + ctx.block() + .cond_br(&pass, &continue_label, &fact.side_exit_label); + + ctx.current_block = continue_idx; + if let Some(active) = ctx.versioned_indexed_loop_facts.last_mut() { + active.live_array_handles = live_handles; + } + true +} + +pub(super) fn lower( + ctx: &mut FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&Expr>, + update: Option<&Expr>, + body: &[Stmt], +) -> Result { + let Some(candidate) = match_candidate(ctx, init, condition, update, body) else { + return Ok(false); + }; + + let fast_pre_idx = ctx.new_block("versioned_index.loop.fast.preheader"); + let slow_pre_idx = ctx.new_block("versioned_index.loop.slow.preheader"); + let merge_idx = ctx.new_block("versioned_index.loop.merge"); + let convert_idx = ctx.new_block("versioned_index.bound.convert"); + let fast_pre_label = ctx.block_label(fast_pre_idx); + let slow_pre_label = ctx.block_label(slow_pre_idx); + let merge_label = ctx.block_label(merge_idx); + let convert_label = ctx.block_label(convert_idx); + + let bound_slot = ctx + .locals + .get(&candidate.bound_id) + .expect("matched local bound has storage") + .clone(); + let bound_box = ctx.block().load(DOUBLE, &bound_slot); + let bound_is_i32 = crate::codegen::emit_typed_arg_guard( + ctx.block(), + crate::codegen::TypedParamRep::I32, + &bound_box, + ); + ctx.block() + .cond_br(&bound_is_i32, &convert_label, &slow_pre_label); + + ctx.current_block = convert_idx; + let bound_i32 = crate::codegen::emit_typed_arg_to_raw( + ctx.block(), + crate::codegen::TypedParamRep::I32, + &bound_box, + ); + let bound_nonnegative = ctx.block().icmp_sge(I32, &bound_i32, "0"); + + let array_entry_idxs: Vec = candidate + .arrays + .iter() + .map(|_| ctx.new_block("versioned_index.array.admit")) + .collect(); + let array_entry_labels: Vec = array_entry_idxs + .iter() + .map(|idx| ctx.block_label(*idx)) + .collect(); + let method_entry_idx = ctx.new_block("versioned_index.method.admit"); + let method_entry_label = ctx.block_label(method_entry_idx); + ctx.block() + .cond_br(&bound_nonnegative, &array_entry_labels[0], &slow_pre_label); + + let mut array_facts = Vec::with_capacity(candidate.arrays.len()); + for (position, local_id) in candidate.arrays.iter().copied().enumerate() { + ctx.current_block = array_entry_idxs[position]; + let next = array_entry_labels + .get(position + 1) + .map(String::as_str) + .unwrap_or(method_entry_label.as_str()); + let (local_slot, expected_fingerprint) = + emit_array_admission(ctx, local_id, &bound_i32, next, &slow_pre_label) + .expect("matched array local has storage"); + array_facts.push(VersionedIndexedArrayFact { + local_id, + local_slot, + expected_fingerprint, + }); + } + + ctx.current_block = method_entry_idx; + if let Some(filter_id) = candidate.filter_id { + let filter_slot = ctx + .locals + .get(&filter_id) + .expect("matched filter local has storage") + .clone(); + let filter_box = ctx.block().load(DOUBLE, &filter_slot); + let filter_bits = ctx.block().bitcast_double_to_i64(&filter_box); + let filter_is_undefined = ctx.block().icmp_eq( + I64, + &filter_bits, + &crate::nanbox::TAG_UNDEFINED_I64.to_string(), + ); + let filter_ok_idx = ctx.new_block("versioned_index.filter.falsy"); + let filter_ok_label = ctx.block_label(filter_ok_idx); + ctx.block() + .cond_br(&filter_is_undefined, &filter_ok_label, &slow_pre_label); + ctx.current_block = filter_ok_idx; + } + + let expected_class_id = ctx + .class_ids + .get(&candidate.class_name) + .expect("matched class has a runtime id") + .to_string(); + let keys_global = ctx + .class_keys_globals + .get(&candidate.class_name) + .expect("matched class has a keys global") + .clone(); + let expected_shape_id = + crate::typed_shape::load_class_shape_id(ctx, &candidate.class_name, &keys_global); + let key_idx = ctx.strings.intern(&candidate.method_name); + let method_guard_slot = (ctx.strings.entry(key_idx).dispatch_hash & 0xffff).to_string(); + let this_slot = ctx + .this_stack + .last() + .expect("matched method body has this storage") + .clone(); + let this_box = ctx.block().load(DOUBLE, &this_slot); + crate::lower_call::emit_inline_direct_method_shape_guard( + ctx, + &this_box, + &expected_class_id, + &expected_shape_id, + &method_guard_slot, + &fast_pre_label, + &slow_pre_label, + ); + + let method_fact = VersionedIndexedMethodFact { + class_name: candidate.class_name.clone(), + method_name: candidate.method_name.clone(), + this_slot, + expected_class_id, + expected_shape_id, + method_guard_slot, + }; + ctx.current_block = fast_pre_idx; + ctx.versioned_indexed_loop_facts + .push(VersionedIndexedLoopFact { + counter_local_id: candidate.counter_id, + falsy_local_id: candidate.filter_id, + side_exit_label: slow_pre_label.clone(), + arrays: array_facts, + method: method_fact, + live_array_handles: HashMap::new(), + }); + super::loops::lower_for_after_init_with_i32_bound( + ctx, + init, + condition, + update, + body, + "for.versioned_index_fast", + Some((candidate.counter_id, bound_i32.clone())), + )?; + ctx.versioned_indexed_loop_facts.pop(); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = slow_pre_idx; + super::loops::lower_for_after_init( + ctx, + init, + condition, + update, + body, + "for.versioned_index_slow", + )?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + ctx.current_block = merge_idx; + Ok(true) +} diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 6bb7e8f9d9..c28763954a 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -140,6 +140,11 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { // operands, the non-BigInt bitwise fast path. Expr::Uint8ArrayGet { index, .. } => is_numeric_expr(ctx, index), Expr::BufferIndexGet { .. } | Expr::Uint8ArrayLength(_) | Expr::BufferLength(_) => true, + Expr::IndexGet { .. } + if crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, e) => + { + true + } Expr::LocalGet(id) => { ctx.element_shape_loop_facts .iter() @@ -522,6 +527,11 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { pub(crate) fn expr_produces_canonical_raw_f64(ctx: &FnCtx<'_>, e: &Expr) -> bool { match e { Expr::Integer(_) | Expr::Number(_) => true, + Expr::IndexGet { .. } + if crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, e) => + { + true + } Expr::Binary { .. } => { is_numeric_expr(ctx, e) && is_provably_not_bigint(ctx, e) diff --git a/crates/perry-codegen/src/type_analysis/pod.rs b/crates/perry-codegen/src/type_analysis/pod.rs index 628ea91391..683bf1e619 100644 --- a/crates/perry-codegen/src/type_analysis/pod.rs +++ b/crates/perry-codegen/src/type_analysis/pod.rs @@ -552,6 +552,9 @@ pub(crate) fn numeric_proof_is_declared_only(ctx: &FnCtx<'_>, expr: &Expr) -> bo !ptr_shape_numeric } Expr::IndexGet { object, index } => { + if crate::stmt::stable_packed_loop::has_numeric_index_fact(ctx, expr) { + return false; + } if !expr_may_return_boxed_value_from_raw_f64_fallback(ctx, expr) { return false; } diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index ccc939fbf5..636b9fdc20 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -174,8 +174,8 @@ pub(crate) use indexing::test_swap_array_index_fast_path_invalidated; // store needs for a `class X extends Array` receiver. pub(crate) use self::subclass::{ array_object_set_length, array_subclass_fast_index_get, array_subclass_fast_length, - is_array_subclass_class_id, is_array_subclass_value, maintain_array_exotic_length, - note_array_subclass_index_write, + clear_packed_subclass_numeric_proof, is_array_subclass_class_id, is_array_subclass_value, + note_array_subclass_index_write, note_packed_subclass_spill_store, }; // Issue #1572 — flatten helpers reused by `node_stream::ns_iter_flat_map` // so an `async function*` mapper return is driven through the iterator diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index dbf6b8e5d2..2650312f5f 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -14,6 +14,22 @@ use crate::array::{js_array_alloc_with_length, note_array_slot, ArrayHeader}; use crate::object::ObjectHeader; use crate::value::JSValue; +// #8690: `ObjectMeta::flags` carries the move-stable scalar payload for a +// numeric packed-prefix proof. The GcHeader authority bit prevents a record +// surviving address reuse: fresh allocations have it clear, and both words +// ride an evacuation without a side-table re-key walk. +// +// bit 0 existing custom-[[Prototype]] flag +// bit 1 payload valid +// bits 8..31 verified numeric prefix bound (24 bits, max 16,000,000) +// bits 32..63 exact semantic ShapeId +const PACKED_NUMERIC_META_VALID: u64 = 1 << 1; +const PACKED_NUMERIC_META_BOUND_SHIFT: u32 = 8; +const PACKED_NUMERIC_META_BOUND_MASK: u64 = 0x00FF_FFFF << PACKED_NUMERIC_META_BOUND_SHIFT; +const PACKED_NUMERIC_META_SHAPE_MASK: u64 = 0xFFFF_FFFF_0000_0000; +const PACKED_NUMERIC_META_MASK: u64 = + PACKED_NUMERIC_META_VALID | PACKED_NUMERIC_META_BOUND_MASK | PACKED_NUMERIC_META_SHAPE_MASK; + // #8655: Array-subclass instances use ordinary ObjectHeader property slots, // but their hot numeric reads have a much stronger invariant than a generic // object lookup can exploit: `push` appends the own keys `"0"`, `"1"`, ... in @@ -247,6 +263,11 @@ fn dense_layout_for_value(value: f64) -> Option<(*const ObjectHeader, DenseSubcl { return None; } + // This is per receiver, not per ShapeId. A cached layout built before + // Object.setPrototypeOf must not let this object borrow the old proof. + if crate::object::prototype_chain::object_has_prototype_override(obj as usize) { + return None; + } let (class_id, shape_id) = unsafe { ((*obj).class_id, (*obj).parent_class_id) }; let key = dense_cache_key(class_id, shape_id); let layout = cached_dense_layout(key).or_else(|| { @@ -257,6 +278,154 @@ fn dense_layout_for_value(value: f64) -> Option<(*const ObjectHeader, DenseSubcl Some((obj, layout)) } +/// Clear an established Array-subclass numeric-prefix proof before an owner +/// field store. `layout_note_slot` calls this for inline slots; the object-owned +/// spill path calls it against the owner because its physical store is noted +/// on the child Array buffer instead. +#[inline] +pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) { + let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { + return; + }; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header._reserved & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF == 0 + { + return; + } + let header = std::ptr::from_ref(header).cast_mut(); + // Retire the authority first. A missing/moving meta then merely leaves an + // unreachable payload, never a proof a future query can consume. + (*header)._reserved &= !crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF; + let meta = (*obj).meta; + if !meta.is_null() { + (*meta).flags &= !PACKED_NUMERIC_META_MASK; + } +} + +/// Owner-side invalidation for an object-owned spill write. The common +/// no-proof case uses the meta pointer the spill path already loaded and pays +/// only one predictable bit test; it does not re-read the owner's GC header. +#[inline] +pub(crate) unsafe fn note_packed_subclass_spill_store( + obj: *mut ObjectHeader, + meta: *mut crate::object::ObjectMeta, +) { + if !meta.is_null() && (*meta).flags & PACKED_NUMERIC_META_VALID != 0 { + clear_packed_subclass_numeric_proof(obj); + } +} + +#[inline] +unsafe fn subclass_numeric_prefix_is_proven( + obj: *const ObjectHeader, + shape_id: u32, + bound: u32, +) -> bool { + let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { + return false; + }; + let header = std::ptr::from_ref(header).cast_mut(); + if (*header)._reserved & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF == 0 { + return false; + } + let meta = (*obj).meta; + if meta.is_null() { + (*header)._reserved &= !crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF; + return false; + } + let flags = (*meta).flags; + let payload_valid = flags & PACKED_NUMERIC_META_VALID != 0; + let proven_bound = + ((flags & PACKED_NUMERIC_META_BOUND_MASK) >> PACKED_NUMERIC_META_BOUND_SHIFT) as u32; + let proven_shape = (flags >> 32) as u32; + if payload_valid && proven_shape == shape_id && proven_bound >= bound { + return true; + } + clear_packed_subclass_numeric_proof(obj as *mut ObjectHeader); + false +} + +#[inline] +unsafe fn publish_subclass_numeric_prefix( + obj: *const ObjectHeader, + shape_id: u32, + bound: u32, +) -> bool { + let meta = (*obj).meta; + if meta.is_null() || bound > 16_000_000 { + return false; + } + let flags = (*meta).flags; + (*meta).flags = (flags & !PACKED_NUMERIC_META_MASK) + | PACKED_NUMERIC_META_VALID + | (u64::from(bound) << PACKED_NUMERIC_META_BOUND_SHIFT) + | (u64::from(shape_id) << 32); + let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { + return false; + }; + let header = std::ptr::from_ref(header).cast_mut(); + (*header)._reserved |= crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF; + true +} + +/// Establish-or-confirm the numeric prefix used by the call-free loop clone. +/// The first visit scans; later visits are two scalar-word checks. Any owner +/// store retires the record before writing, and semantic shape changes fail +/// the exact ShapeId comparison even if they do not touch a value slot. +#[inline] +unsafe fn ensure_subclass_numeric_prefix( + obj: *const ObjectHeader, + layout: DenseSubclassLayout, + bound: u32, +) -> bool { + if bound == 0 { + return true; + } + let shape_id = (*obj).parent_class_id; + if subclass_numeric_prefix_is_proven(obj, shape_id, bound) { + return true; + } + for index in 0..bound { + let Some(slot) = layout.element_base.checked_add(index) else { + return false; + }; + let value_ptr = if slot < layout.live_inline_slots { + (obj as *mut u8) + .add(std::mem::size_of::()) + .cast::() + .add(slot as usize) + } else { + let meta = (*obj).meta; + if meta.is_null() { + return false; + } + let spill = (*meta).spill as *mut ArrayHeader; + if spill.is_null() || slot >= (*spill).length { + return false; + } + (spill as *mut u8) + .add(std::mem::size_of::()) + .cast::() + .add(slot as usize) + }; + let value = JSValue::from_bits(ptr::read(value_ptr)); + if value.is_int32() { + // `push(i)` commonly stores Perry's compact INT32 Number tag. The + // direct clone consumes raw doubles, so normalize that Number to + // its representation-equivalent f64 bits during the one-time + // verification walk. This is pointer-free -> pointer-free and + // changes no JS-observable type/value, hence needs neither a GC + // barrier nor a layout downgrade. + // GC_STORE_AUDIT(POINTER_FREE): canonical raw-f64 Number bits + // replace compact int32 Number bits in an already numeric slot. + ptr::write(value_ptr, (value.as_int32() as f64).to_bits()); + } else if !value.is_number() { + return false; + } + } + publish_subclass_numeric_prefix(obj, shape_id, bound) +} + #[inline] fn layout_length_value(obj: *const ObjectHeader, layout: DenseSubclassLayout) -> JSValue { layout_field_value(obj, layout.length_slot, layout.live_inline_slots) @@ -413,6 +582,122 @@ pub extern "C" fn js_packed_arraylike_index_get(receiver: f64, index: f64, cache crate::value::js_dyn_index_get(receiver, index) } +/// Admit a complete counted-loop range over either an ordinary Array or an +/// object-backed Array subclass. The seven output words are scalar facts, not +/// managed pointers, so the generated loop can reload a relocated receiver +/// from its root before each residual check. +/// +/// Layout: `(kind, gc_header, receiver_header, length_slot, element_base, +/// dense_prefix|inline_bound<<32, bound)`. Kind 1 is an ArrayHeader and kind 2 +/// is an ObjectHeader Array subclass. A zero return leaves every semantic case +/// to the unchanged generic loop. +#[no_mangle] +pub extern "C" fn js_packed_arraylike_loop_guard( + receiver: f64, + bound: f64, + require_numeric: i32, + out: *mut u64, +) -> i32 { + let live_length_bound = bound == -1.0; + if out.is_null() + || !bound.is_finite() + || (!live_length_bound && bound < 0.0) + || (!live_length_bound && bound.fract() != 0.0) + || bound > 16_000_000.0 + { + return 0; + } + let requested_bound = (!live_length_bound).then_some(bound as u32); + let js = JSValue::from_bits(receiver.to_bits()); + if !js.is_pointer() { + return 0; + } + let raw = js.as_pointer::(); + let Some(header) = (unsafe { crate::value::addr_class::try_read_gc_header(raw as usize) }) + else { + return 0; + }; + if header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + return 0; + } + + if header.obj_type == crate::gc::GC_TYPE_ARRAY { + if header._reserved & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS != 0 + || super::PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED.load(Ordering::Relaxed) != 0 + { + return 0; + } + let array = raw.cast::(); + let (length, capacity) = unsafe { ((*array).length, (*array).capacity) }; + let bound = requested_bound.unwrap_or(length); + if bound > length || length > capacity || capacity > 16_000_000 { + return 0; + } + if require_numeric != 0 { + // The raw-f64 invariant is an O(1) GcHeader bit after its first + // self-healing scan, and every nonnumeric Array write already + // clears it. Reuse that representation proof instead of walking + // the full range on every invocation of the surrounding scan(). + if !unsafe { super::header::ensure_array_numeric_raw_f64(array as *mut ArrayHeader) } { + return 0; + } + } + let gc_word = unsafe { ptr::read_unaligned((raw as *const u8).sub(8).cast::()) }; + let array_word = (u64::from(capacity) << 32) | u64::from(length); + unsafe { + out.add(0).write(1); + out.add(1).write(gc_word); + out.add(2).write(array_word); + out.add(3).write(0); + out.add(4).write(0); + out.add(5).write(0); + out.add(6).write(u64::from(bound)); + } + return 1; + } + + if header.obj_type != crate::gc::GC_TYPE_OBJECT { + return 0; + } + let Some((object, layout)) = dense_layout_for_value(receiver) else { + return 0; + }; + if !crate::object::object_spill_enabled() || layout.length_slot >= layout.live_inline_slots { + return 0; + } + let Some(length) = nonnegative_u32_length(layout_length_value(object, layout)) else { + return 0; + }; + let bound = requested_bound.unwrap_or(length); + if bound > length || bound > layout.dense_prefix_len || length > 16_000_000 { + return 0; + } + if require_numeric != 0 { + if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound) } { + return 0; + } + } + let gc_word = unsafe { ptr::read_unaligned((raw as *const u8).sub(8).cast::()) }; + let receiver_word = unsafe { ptr::read_unaligned(raw.cast::()) }; + unsafe { + out.add(0).write(2); + out.add(1).write(gc_word); + out.add(2).write(receiver_word); + out.add(3).write(u64::from(layout.length_slot)); + out.add(4).write(u64::from(layout.element_base)); + out.add(5).write( + (u64::from(layout.live_inline_slots) << 32) | u64::from(layout.dense_prefix_len), + ); + out.add(6).write(u64::from(bound)); + } + 2 +} + +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_JS_PACKED_ARRAYLIKE_LOOP_GUARD: extern "C" fn(f64, f64, i32, *mut u64) -> i32 = + js_packed_arraylike_loop_guard; + #[cfg(feature = "keepalive-anchors")] #[used] static KEEP_JS_PACKED_ARRAYLIKE_INDEX_GET: extern "C" fn(f64, f64, *mut u64) -> f64 = @@ -669,7 +954,7 @@ pub(crate) fn array_object_index_set(recv: f64, index: u32, value: f64) { maintain_array_exotic_length(handle.get_nanbox_f64(), index); } -/// The Array-exotic `length` step for an indexed own-property write, applied by +/// The Array-exotic post-step for an indexed own-property write, applied by /// the two generic OBJECT store funnels (`js_put_value_set` and /// `js_object_set_index_polymorphic`) AFTER the store has landed. /// @@ -684,10 +969,16 @@ pub(crate) fn array_object_index_set(recv: f64, index: u32, value: f64) { /// on a bounded parent walk. `key` is a property-key VALUE; a non-canonical /// array index (`"length"`, `"foo"`, `"01"`, a symbol) is a no-op. pub(crate) fn note_array_subclass_index_write(recv: f64, key: f64) { - if !is_array_subclass_value(recv) { + // Stringifying a numeric key can allocate and evacuate the object. Keep + // both inputs live, then re-read the receiver before retiring its proof. + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_h = scope.root_nanbox_f64(recv); + let key_h = scope.root_nanbox_f64(key); + if !is_array_subclass_value(recv_h.get_nanbox_f64()) { return; } - let key_ptr = crate::value::js_jsvalue_to_string(key) as *const crate::string::StringHeader; + let key_ptr = crate::value::js_jsvalue_to_string(key_h.get_nanbox_f64()) + as *const crate::string::StringHeader; // The `&str` borrows the heap `StringHeader`'s bytes. `canonical_array_index` // only parses digits — it allocates nothing, so the borrow cannot straddle a // collection point (the `&[u8]`-into-a-StringHeader hazard in CLAUDE.md). @@ -699,7 +990,13 @@ pub(crate) fn note_array_subclass_index_write(recv: f64, key: f64) { None => return, } }; - maintain_array_exotic_length(recv, index); + let live_recv = recv_h.get_nanbox_f64(); + let raw = (live_recv.to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; + // A successful value overwrite does not necessarily change shape and a + // pointer-free tag (notably an SSO string) needs no GC layout note. Retire + // the numeric authority explicitly so neither case can reuse stale proof. + unsafe { clear_packed_subclass_numeric_proof(raw) }; + maintain_array_exotic_length(live_recv, index); } /// The `length`-bumping half of `array_object_index_set`, split out so the diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index 980cf338aa..84083167f4 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -19,7 +19,8 @@ use super::subclass::{ array_object_receiver, array_subclass_fast_index_get, array_subclass_fast_length, - is_array_subclass_class_id, js_packed_arraylike_index_get, raw_receiver_is_heap_object, + is_array_subclass_class_id, js_packed_arraylike_index_get, js_packed_arraylike_loop_guard, + raw_receiver_is_heap_object, }; use crate::array::{clean_arr_ptr, js_array_alloc, ArrayHeader}; use crate::object::{js_object_alloc, ObjectHeader}; @@ -202,6 +203,68 @@ fn dense_array_subclass_reads_slots_until_its_shape_changes() { ); } +/// #8690: pointer-free tagged values skip the GC write barrier. The generic +/// successful-index hook must still retire a numeric-prefix proof, otherwise a +/// later loop clone would reinterpret the SSO bits as an f64 Number. +#[test] +fn packed_numeric_proof_is_retired_by_sso_index_overwrite() { + let class_id = 0x0074_8690; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + assert!(!obj.is_null()); + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + crate::node_stream::js_array_subclass_init(receiver_h.get_nanbox_f64(), 0.0); + for (index, value) in [11.0, 22.0, 33.0].into_iter().enumerate() { + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, index as f64, value); + } + + let mut facts = [0u64; 7]; + assert_eq!( + js_packed_arraylike_loop_guard(receiver_h.get_nanbox_f64(), 3.0, 1, facts.as_mut_ptr(),), + 2, + "the numeric object-backed range should establish a proof" + ); + let live_raw = (receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut u8; + let header = unsafe { crate::value::addr_class::try_read_gc_header(live_raw as usize) } + .expect("the rooted receiver is a live GC object"); + assert_ne!( + header._reserved & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF, + 0 + ); + + let key_ptr = crate::string::js_string_from_bytes(b"1".as_ptr(), 1); + let key = f64::from_bits(crate::value::js_nanbox_string(key_ptr as i64).to_bits()); + let sso = f64::from_bits( + crate::value::JSValue::try_short_string(b"9") + .expect("one byte is an inline SSO") + .bits(), + ); + crate::proxy::js_put_value_set( + receiver_h.get_nanbox_f64(), + key, + sso, + receiver_h.get_nanbox_f64(), + 0, + ); + + let live_raw = (receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut u8; + let header = unsafe { crate::value::addr_class::try_read_gc_header(live_raw as usize) } + .expect("the rooted receiver is a live GC object"); + assert_eq!( + header._reserved & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF, + 0, + "a successful SSO overwrite must retire numeric authority without a GC barrier" + ); + assert_eq!( + js_packed_arraylike_loop_guard(receiver_h.get_nanbox_f64(), 3.0, 1, facts.as_mut_ptr(),), + 0, + "the next numeric loop must side-exit after an element-kind transition" + ); +} + #[test] fn dense_array_subclass_guard_rejects_other_object_brands() { let obj = js_object_alloc(0x0074_8656, 2); diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index ec8ec6d4df..712a5f5751 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -709,6 +709,16 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits slot_index, value_bits, ); + } else if (*header).obj_type == GC_TYPE_OBJECT + && (*header)._reserved & OBJ_FLAG_PACKED_NUMERIC_PROOF != 0 + { + // #8690: the proof payload lives with ObjectMeta, but this + // GcHeader bit is its cheap authority. Retire it before an inline + // owner store; object-owned spill stores use the matching owner + // hook because their physical layout note names the spill Array. + crate::array::clear_packed_subclass_numeric_proof( + parent_user as *mut crate::object::ObjectHeader, + ); } if (*header)._reserved & GC_LAYOUT_STATE_MASK == GC_LAYOUT_UNKNOWN { return; diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index d539d815e2..41f75f72a6 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1072,6 +1072,18 @@ pub const OBJ_FLAG_NO_EXTEND: u16 = 0x04; // (`GC_COPY_SURVIVAL_AGE_MASK = 0x0038`) and bits 14..15 the layout state, // so 0x08 would be clobbered on every minor GC. Bits 6..13 are free. pub const OBJ_FLAG_NULL_PROTO: u16 = 0x40; +/// #8690: this `GC_TYPE_OBJECT` carries a cached proof that the packed +/// Array-subclass element prefix recorded in `ObjectMeta::flags` is numeric. +/// The bit is the address-reuse-safe authority: fresh allocations start with +/// it clear, and the whole `_reserved` word rides copying/compacting GC moves. +/// Every ordinary object-slot store clears it through `layout_note_slot`; the +/// object-owned spill store has the matching owner-side hook. +/// +/// Bit 7 is shared with `GC_ARRAY_RAW_F64_LAYOUT`, which is only meaningful +/// for `GC_TYPE_ARRAY`. The two facts deliberately mean the same thing to the +/// loop guard — direct loads over the admitted prefix are raw numeric f64s — +/// but their payload layouts and invalidation funnels remain type-specific. +pub(crate) const OBJ_FLAG_PACKED_NUMERIC_PROOF: u16 = 0x80; // Array carries per-index property descriptors (accessors or custom attrs // installed via `Object.defineProperty`, or a non-writable `length`). The // raw-f64 numeric fast paths must decline and route through the diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e1672a4ca8..60200bc694 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1747,11 +1747,13 @@ pub struct ObjectMeta { /// Same summary for accessor descriptors (`get`/`set` installs) — the /// `accessor_descriptors` table twin of `attr_key_bits`. pub accessor_key_bits: u64, - /// Object-only state that cannot share `GcHeader._reserved`: every bit in - /// that 16-bit word is already owned by GC layout/age or another object - /// flag. In particular, bit 12 is `GC_OBJ_TYPED_LAYOUT_INTACT`, so using - /// it for prototype divergence made every typed-layout object appear to - /// have a custom prototype. + /// Object-only state and compact scalar proof payloads. Bit 0 is the + /// custom-prototype flag. #8690 reserves bit 1 plus bits 8..63 for the + /// packed Array-subclass numeric-prefix proof (verified bound + ShapeId); + /// its address-reuse-safe authority is a type-specific GcHeader bit. + /// In particular, GcHeader bit 12 is `GC_OBJ_TYPED_LAYOUT_INTACT`, so + /// using that word for prototype divergence made every typed-layout + /// object appear to have a custom prototype. pub flags: u64, /// #6812: object-owned overflow storage — a `GC_TYPE_ARRAY` buffer /// (`*mut ArrayHeader` bits, 0 = none) holding the NaN-boxed values of diff --git a/crates/perry-runtime/src/object/polymorphic_index.rs b/crates/perry-runtime/src/object/polymorphic_index.rs index 4dcc4cc859..1248757dfa 100644 --- a/crates/perry-runtime/src/object/polymorphic_index.rs +++ b/crates/perry-runtime/src/object/polymorphic_index.rs @@ -484,6 +484,11 @@ pub extern "C" fn js_object_set_index_polymorphic(obj_handle: i64, idx: f64, val // Stringify the index and route through the object field setter, // which handles shape transitions, frozen/sealed/extensible checks, // overflow into out-of-line storage, and accessor descriptors. + // Keep the receiver/key alive because the setter can allocate and the + // Array-subclass post-step below must observe their evacuated values. + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_h = scope.root_nanbox_f64(boxed); + let key_h = scope.root_nanbox_f64(idx); unsafe { rooted_property_key_set(raw, idx, value) }; // #7574: a `class X extends Array` instance IS an Array in JavaScript, // so `sub[3] = v` runs the Array-exotic `[[DefineOwnProperty]]` and @@ -493,13 +498,10 @@ pub extern "C" fn js_object_set_index_polymorphic(obj_handle: i64, idx: f64, val // then made the next `sub.push(v)` append at index 0 and overwrite it. // Ordinary objects and object literals never reach the chain walk: the // `class_id == 0` test short-circuits first. - let class_id = crate::object::js_object_get_class_id(raw as *const ObjectHeader); - if class_id != 0 && crate::array::is_array_subclass_class_id(class_id) { - if let Some(index) = numeric_key_u32_index(idx) { - let recv = f64::from_bits(crate::value::POINTER_TAG | raw); - crate::array::maintain_array_exotic_length(recv, index); - } - } + crate::array::note_array_subclass_index_write( + recv_h.get_nanbox_f64(), + key_h.get_nanbox_f64(), + ); return; } // Buffer / typed-array were handled above. Map / Set are collection diff --git a/crates/perry-runtime/src/object/spill.rs b/crates/perry-runtime/src/object/spill.rs index cbf4299547..fff7c149ca 100644 --- a/crates/perry-runtime/src/object/spill.rs +++ b/crates/perry-runtime/src/object/spill.rs @@ -121,6 +121,10 @@ pub(crate) fn spill_set(obj_ptr: usize, field_index: usize, vbits: u64) { // handle scope is needed. This is every write after the first to a // given width (e.g. round-robin updates across an object array). let meta = (*obj).meta; + // The physical write below belongs to the spill Array, so its layout + // note cannot identify the owning Array-subclass object. Retire that + // owner's cached numeric-prefix proof before changing any spill slot. + crate::array::note_packed_subclass_spill_store(obj, meta); if !meta.is_null() { let spill = (*meta).spill as *mut crate::array::ArrayHeader; if !spill.is_null() && ((*spill).capacity as usize) > field_index { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 63fe558d2e..996a23cc8a 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -3090,9 +3090,10 @@ mod tests { "perry-codegen emits 0x200 for this bit" ); // It ADMITS a receiver, so it must not appear in the mask that REJECTS - // one (`WRITE_PIC_BLOCKING_FLAGS = 0x1907`) — a collision would make + // one (`WRITE_PIC_BLOCKING_FLAGS = 0x1987`) — a collision would make // every marked object permanently ineligible. - assert_eq!(crate::gc::OBJ_FLAG_PLAIN_ORDINARY & 0x1907, 0); + assert_eq!(crate::gc::OBJ_FLAG_PLAIN_ORDINARY & 0x1987, 0); + assert_ne!(crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF & 0x1987, 0); // Bit 9 is shared with the array-only arguments-object flag, disjoint // by `obj_type`; and it must not collide with any object-meaningful // flag or with the survival-age / layout-state fields the GC owns. diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index e5e459f858..3461e73118 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -476,7 +476,10 @@ fn class_field_set_fast_contract( let Some(gc_header) = gc_header_for_user_addr(object_addr) else { return false; }; - if (*gc_header)._reserved & crate::gc::OBJ_FLAG_FROZEN != 0 { + if (*gc_header)._reserved + & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF) + != 0 + { return false; } } @@ -547,7 +550,10 @@ fn class_field_set_contract( if (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { return (0, 0, gc_type, false); } - if (*gc_header)._reserved & crate::gc::OBJ_FLAG_FROZEN != 0 { + if (*gc_header)._reserved + & (crate::gc::OBJ_FLAG_FROZEN | crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF) + != 0 + { let obj = object_addr as *mut ObjectHeader; return ( crate::object::shapes::object_shape_id(obj) as usize, @@ -1058,7 +1064,9 @@ pub unsafe extern "C" fn js_method_direct_shape_class( }; if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT || (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 - || (*gc_header)._reserved & crate::gc::OBJ_FLAG_HAS_DESCRIPTORS != 0 + || (*gc_header)._reserved + & (crate::gc::OBJ_FLAG_HAS_DESCRIPTORS | crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF) + != 0 || crate::object::class_prototype_fast_guard_invalidated_for_method(method_guard_slot) { return 0; diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 61a6a39c25..5238ef437e 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1583,6 +1583,51 @@ fn typed_feedback_class_field_set_guard_fails_for_frozen_object() { assert_eq!(site.fallback_calls, 0); } +/// #8690: a packed Array-subclass numeric proof is authoritative even for +/// pointer-free values. The class-field set guard must miss while the bit is +/// active so the runtime setter can retire it for SSO and boolean overwrites. +#[test] +fn typed_feedback_class_field_set_guard_retires_packed_numeric_proof_for_tagged_values() { + let _guard = typed_feedback_test_lock(); + reset_typed_feedback_for_tests(); + register(8_690, TypedFeedbackSiteKind::PropertySet, "obj.x="); + + let class_id = 0x7EED_8690; + let (obj, _, key, receiver) = class_instance(class_id, b"x"); + let expected_shape_id = shape_id(obj); + crate::object::js_object_set_field(obj, 0, crate::JSValue::number(1.0)); + let header = + unsafe { (obj as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader }; + let short = crate::value::JSValue::try_short_string(b"s").expect("inline SSO"); + + for (name, value_bits) in [("SSO", short.bits()), ("boolean", crate::value::TAG_TRUE)] { + unsafe { + (*header)._reserved |= crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF; + } + let value = f64::from_bits(value_bits); + assert_eq!( + js_typed_feedback_class_field_set_guard( + 8_690, + receiver, + class_id, + expected_shape_id, + key, + 0, + value, + 0, + ), + 0, + "{name} must not bypass packed numeric proof invalidation" + ); + crate::object::js_object_set_field(obj, 0, crate::JSValue::from_bits(value_bits)); + assert_eq!( + unsafe { (*header)._reserved } & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF, + 0, + "the runtime setter must retire proof authority for {name}" + ); + } +} + #[test] fn typed_feedback_class_field_set_guard_falls_back_for_class_setter() { let _guard = typed_feedback_test_lock(); diff --git a/crates/perry/tests/issue_8655_array_subclass_indexing.rs b/crates/perry/tests/issue_8655_array_subclass_indexing.rs index 0d52c50420..7349b03c92 100644 --- a/crates/perry/tests/issue_8655_array_subclass_indexing.rs +++ b/crates/perry/tests/issue_8655_array_subclass_indexing.rs @@ -81,8 +81,23 @@ fn function_ir<'a>(ir: &'a str, function_fragment: &str) -> &'a str { &tail[..end + 2] } +fn named_blocks(function: &str, prefixes: &[&str]) -> String { + let mut selected = false; + let mut result = String::new(); + for line in function.lines() { + if !line.starts_with([' ', '\t']) && line.contains(':') { + selected = prefixes.iter().any(|prefix| line.contains(prefix)); + } + if selected { + result.push_str(line); + result.push('\n'); + } + } + result +} + #[test] -fn wolf_ecs_loop_has_no_generic_dynamic_index_get() { +fn wolf_ecs_loop_has_a_fallback_free_versioned_fast_copy() { let dir = tempfile::tempdir().expect("tempdir"); let (_bin, stderr) = compile(dir.path(), issue_repro_source(), true); let ll_path = stderr @@ -95,9 +110,25 @@ fn wolf_ecs_loop_has_no_generic_dynamic_index_get() { let _ = std::fs::remove_file(&ll_path); let system = function_ir(&ir, "__system(double"); + assert!( + system.contains("call i32 @js_packed_arraylike_loop_guard("), + "the original #8655 fixture must enter through the loop preheader proof" + ); + assert!(system.contains("stable_packed.loop.fast.preheader")); + assert!( + !system.contains("stable_packed.iteration.fast"), + "the call-free clone must not repeat its complete preheader proof per iteration" + ); + assert!(system.contains("stable_packed.loop.slow.preheader")); + let fast_blocks = named_blocks(system, &["stable_packed", "for.stable_packed_fast"]); + assert!( + !fast_blocks.contains("js_object_get_index_polymorphic") + && !fast_blocks.contains("js_packed_arraylike_index_get"), + "the original Wolf ECS fast copy must use private direct loads\n{fast_blocks}" + ); assert!( system.contains("call double @js_packed_arraylike_index_get("), - "the unknown Array-subclass receiver must use the guarded packed-arraylike read" + "the explicit generic loop copy must retain the guarded semantic fallback" ); assert!( !system.contains("call double @js_dyn_index_get("), diff --git a/crates/perry/tests/issue_8690_loop_versioned_arraylike.rs b/crates/perry/tests/issue_8690_loop_versioned_arraylike.rs new file mode 100644 index 0000000000..b1eda124b3 --- /dev/null +++ b/crates/perry/tests/issue_8690_loop_versioned_arraylike.rs @@ -0,0 +1,430 @@ +//! Regression coverage for #8690: stable packed Array and Array-subclass +//! counted loops get a fallback-free fast copy and resume the generic copy at +//! the current index whenever a mutation invalidates the admission proof. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn compile(dir: &Path, source: &str, retain_artifacts: bool) -> (PathBuf, String) { + let entry = dir.join("main.ts"); + let output = dir.join("main_bin"); + 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) + .env("PERRY_NO_CACHE", "1"); + if retain_artifacts { + cmd.env("PERRY_LLVM_KEEP_IR", "1") + .env("PERRY_NATIVE_REPS", "1") + .env("PERRY_NATIVE_REPS_DIR", dir.join("native-reps")); + } + let compile = cmd.output().expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + ( + output, + String::from_utf8_lossy(&compile.stderr).into_owned(), + ) +} + +fn run(bin: &Path, dir: &Path, moving_gc: bool) -> Output { + let mut command = Command::new(bin); + command.current_dir(dir); + if moving_gc { + command + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1"); + } + command.output().expect("run compiled binary") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn function_ir<'a>(ir: &'a str, function_fragment: &str) -> &'a str { + let start = ir + .find(function_fragment) + .unwrap_or_else(|| panic!("missing function `{function_fragment}` in emitted IR")); + let body_start = ir[..start] + .rfind("\ndefine ") + .unwrap_or_else(|| panic!("missing definition before `{function_fragment}`")); + let tail = &ir[body_start + 1..]; + let end = tail + .find("\n}\n") + .unwrap_or_else(|| panic!("unterminated definition for `{function_fragment}`")); + &tail[..end + 2] +} + +fn named_blocks(function: &str, prefixes: &[&str]) -> String { + let mut selected = false; + let mut result = String::new(); + for line in function.lines() { + if !line.starts_with([' ', '\t']) && line.contains(':') { + selected = prefixes.iter().any(|prefix| line.contains(prefix)); + } + if selected { + result.push_str(line); + result.push('\n'); + } + } + result +} + +#[test] +fn read_only_loops_have_preheader_proofs_and_fallback_free_fast_blocks() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +class Query extends Array {} +class Archetype extends Array {} + +const entityCount = 10_000; +const iterations = 2_000; +const query = new Query(); +const archetype = new Archetype(); +for (let i = 0; i < entityCount; i++) archetype.push(i); +query.push(archetype); + +function scan(): number { + let sum = 0; + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + sum = (sum + current[j]) | 0; + } + } + return sum; +} + +let checksum = 0; +for (let i = 0; i < iterations; i++) checksum ^= scan(); +console.log(checksum); +"#; + let (bin, stderr) = compile(dir.path(), source, true); + let output = run(&bin, dir.path(), false); + assert_success(&output); + assert_eq!(String::from_utf8_lossy(&output.stdout), "0\n"); + + let ll_path = stderr + .lines() + .find_map(|line| line.split("kept LLVM IR: ").nth(1)) + .map(str::trim) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("PERRY_LLVM_KEEP_IR did not report an IR path\n{stderr}")); + let ir = std::fs::read_to_string(&ll_path).expect("read kept LLVM IR"); + let read_only = function_ir(&ir, "__scan("); + assert_eq!( + read_only + .matches("call i32 @js_packed_arraylike_loop_guard(") + .count(), + 3, + "the outer fast/slow copies each own a preheader-versioned inner loop" + ); + assert!(read_only.contains("stable_packed.loop.fast.preheader")); + assert!(read_only.contains("stable_packed.loop.slow.preheader")); + assert!(read_only.contains("for.stable_packed_fast")); + assert!( + !read_only.contains("stable_packed.iteration.fast"), + "the admitted call-free clone must not repeat header revalidation" + ); + + let fast_blocks = named_blocks(read_only, &["stable_packed", "for.stable_packed_fast"]); + assert!( + fast_blocks.contains("load double"), + "the fast copy must contain a private direct element load\n{fast_blocks}" + ); + assert!( + !fast_blocks.contains("js_object_get_index_polymorphic") + && !fast_blocks.contains("js_packed_arraylike_index_get"), + "the fast copy must not contain an indexed-read runtime fallback\n{fast_blocks}" + ); + for forbidden in [ + "js_dynamic_string_or_number_add", + "js_number_coerce", + "js_gc_loop_safepoint", + ] { + assert!( + !fast_blocks.contains(forbidden), + "the call-free fast copy must not contain `{forbidden}`\n{fast_blocks}" + ); + } + + let artifact_dir = dir.path().join("native-reps"); + let artifact_text = std::fs::read_dir(&artifact_dir) + .expect("read native-reps directory") + .map(|entry| { + let path = entry.expect("native-reps entry").path(); + std::fs::read_to_string(path).expect("read native-reps artifact") + }) + .collect::(); + for required in [ + "proof=preheader_scalar_layout", + "revalidation=none_call_free_clone", + "side_exit=current_index", + "loop_versioning=stable_packed_arraylike_fallback", + ] { + assert!( + artifact_text.contains(required), + "native-region artifact must identify `{required}`" + ); + } +} + +#[test] +fn typed_array_loops_keep_their_width_aware_lowering() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +function xorU32(values: Uint32Array, n: number): number { + let result = 0 | 0; + for (let i = 0; i < n; i++) { + result = (result ^ values[i & 7]) | 0; + } + return result | 0; +} + +const values = Uint32Array.from([ + 1, 4000000000, 0xffffffff, 7, 0x80000000, 0, 42, 999, +]); +console.log(xorU32(values, 8)); +"#; + let (bin, stderr) = compile(dir.path(), source, true); + let output = run(&bin, dir.path(), false); + assert_success(&output); + assert_eq!(String::from_utf8_lossy(&output.stdout), "-1852517324\n"); + + let ll_path = stderr + .lines() + .find_map(|line| line.split("kept LLVM IR: ").nth(1)) + .map(str::trim) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("PERRY_LLVM_KEEP_IR did not report an IR path\n{stderr}")); + let ir = std::fs::read_to_string(&ll_path).expect("read kept LLVM IR"); + let xor = function_ir(&ir, "__xorU32("); + assert!( + !xor.contains("js_packed_arraylike_loop_guard"), + "a statically known TypedArray must not enter Array loop versioning\n{xor}" + ); +} + +#[test] +fn mutations_and_moving_gc_resume_with_generic_semantics() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = r#" +class Dense extends Array {} + +function live(a: any, mutate: (value: any) => void): string { + let out = ""; + for (let i = 0; i < a.length; i++) { + const value = a[i]; + if (i === 0) mutate(a); + out += String(value) + ";"; + } + return out; +} + +function snapshot(a: any, mutate: (value: any) => void): string { + const length = a.length; + let out = ""; + for (let i = 0; i < length; i++) { + const value = a[i]; + if (i === 0) mutate(a); + out += String(value) + ";"; + } + return out; +} + +function noChange(_value: any): void {} + +function packedSum(a: any): number { + const length = a.length; + let sum = 0; + for (let i = 0; i < length; i++) sum = (sum + a[i]) | 0; + return sum; +} + +// A declared Number is not a runtime proof: callers may still provide a +// String or BigInt through `any`. This exercises the assignment-side `| 0` +// lowering used by the numeric clone without letting it skip ToNumber or the +// required mixed-BigInt TypeError. +function declaredToInt32(value: number): number { + let result = 0; + result = value | 0; + return result; +} + +function breakAfterEffect(a: any): string { + let out = ""; + for (let i = 0; i < a.length; i++) { + const value = a[i]; + out += String(value); + break; + } + return out; +} + +console.log("break=" + breakAfterEffect([31, 32])); +console.log("declared-string=" + declaredToInt32("7" as any)); +try { declaredToInt32(1n as any); console.log("declared-bigint=no-throw"); } +catch (_error) { console.log("declared-bigint=throw"); } + +const grow: any[] = [1, 2]; +console.log("grow=" + live(grow, (a: any) => a.push(3))); + +const shrink: any[] = [1, 2, 3]; +console.log("shrink=" + live(shrink, (a: any) => { a.length = 1; })); + +const fixed: any[] = [1, 2]; +console.log("snapshot=" + snapshot(fixed, (a: any) => a.push(3))); + +const mixed: any[] = [1, 2, 3]; +console.log("kind=" + live(mixed, (a: any) => { a[1] = "mixed"; })); + +const aliased: any[] = [4, 5, 6]; +const alias: any = aliased; +console.log("alias=" + live(aliased, (_a: any) => { alias.pop(); })); + +const described: any[] = [7, 8, 9]; +Object.defineProperty(described, "1", { get() { return 41; }, configurable: true }); +console.log("descriptor=" + live(described, noChange)); + +const hole: any = new Dense(); +hole.push(10); hole.push(11); hole.push(12); +delete hole[1]; +const replacement: any = {}; +Object.defineProperty(replacement, "1", { get() { return 77; }, configurable: true }); +Object.setPrototypeOf(hole, replacement); +console.log("hole-prototype=" + live(hole, noChange)); + +const dense: any = new Dense(); +dense.push(13); dense.push(14); dense.push(15); +const proxied: any = new Proxy(dense, { + get(target: any, key: any) { + if (String(key) === "1") return 88; + return target[key]; + } +}); +console.log("proxy=" + live(proxied, noChange)); + +// Establish the call-free clone's preheader proof, then mutate between calls. +// These cases exercise proof retirement itself; the callback cases above +// deliberately stay on the mutation-capable generic clone. +const betweenKind: any = new Dense(); +betweenKind.push(1); betweenKind.push(2); betweenKind.push(3); +console.log("between-kind-before=" + packedSum(betweenKind)); +betweenKind[1] = "9"; +console.log("between-kind-after=" + packedSum(betweenKind)); + +const betweenHole: any = new Dense(); +betweenHole.push(2); betweenHole.push(3); betweenHole.push(4); +console.log("between-hole-before=" + packedSum(betweenHole)); +delete betweenHole[1]; +const betweenPrototype: any = {}; +Object.defineProperty(betweenPrototype, "1", { get() { return 50; }, configurable: true }); +Object.setPrototypeOf(betweenHole, betweenPrototype); +console.log("between-hole-after=" + packedSum(betweenHole)); + +const betweenDescriptor: any = new Dense(); +betweenDescriptor.push(5); betweenDescriptor.push(6); betweenDescriptor.push(7); +console.log("between-descriptor-before=" + packedSum(betweenDescriptor)); +Object.defineProperty(betweenDescriptor, "1", { get() { return 40; }, configurable: true }); +console.log("between-descriptor-after=" + packedSum(betweenDescriptor)); + +const betweenSize: any = new Dense(); +betweenSize.push(8); betweenSize.push(9); +console.log("between-grow-before=" + packedSum(betweenSize)); +betweenSize.push(10); +console.log("between-grow-after=" + packedSum(betweenSize)); +betweenSize.length = 1; +console.log("between-shrink-after=" + packedSum(betweenSize)); + +const betweenProxyTarget: any = new Dense(); +betweenProxyTarget.push(11); betweenProxyTarget.push(12); +console.log("between-proxy-before=" + packedSum(betweenProxyTarget)); +const betweenProxy: any = new Proxy(betweenProxyTarget, { + get(target: any, key: any) { + if (String(key) === "1") return 60; + return target[key]; + } +}); +console.log("between-proxy-after=" + packedSum(betweenProxy)); + +const betweenThrow: any = new Dense(); +betweenThrow.push(13); betweenThrow.push(14); +console.log("between-throw-before=" + packedSum(betweenThrow)); +Object.defineProperty(betweenThrow, "1", { get() { throw new Error("between-getter"); } }); +try { packedSum(betweenThrow); } +catch (error) { console.log("between-throw-after=" + error.message); } + +const throwing: any[] = [16, 17]; +Object.defineProperty(throwing, "0", { get() { throw new Error("getter"); } }); +try { live(throwing, noChange); } catch (error) { console.log("thrown-getter=" + error.message); } +try { live([18, 19], (_a: any) => { throw new Error("callback"); }); } +catch (error) { console.log("thrown-callback=" + error.message); } + +const moved: any = new Dense(); +moved.push(20); moved.push(21); moved.push(22); +console.log("moving-proof-before=" + packedSum(moved)); +console.log("moving=" + live(moved, (_a: any) => { + for (let i = 0; i < 2000; i++) { const garbage = [i, i + 1, i + 2]; } + gc(); +})); +console.log("moving-proof-after=" + packedSum(moved)); +"#; + let (bin, _stderr) = compile(dir.path(), source, false); + let expected = "break=31\n\ + declared-string=7\n\ + declared-bigint=throw\n\ + grow=1;2;3;\n\ + shrink=1;\n\ + snapshot=1;2;\n\ + kind=1;mixed;3;\n\ + alias=4;5;\n\ + descriptor=7;41;9;\n\ + hole-prototype=10;77;12;\n\ + proxy=13;88;15;\n\ + between-kind-before=6\n\ + between-kind-after=22\n\ + between-hole-before=9\n\ + between-hole-after=56\n\ + between-descriptor-before=18\n\ + between-descriptor-after=52\n\ + between-grow-before=17\n\ + between-grow-after=27\n\ + between-shrink-after=8\n\ + between-proxy-before=23\n\ + between-proxy-after=71\n\ + between-throw-before=27\n\ + between-throw-after=between-getter\n\ + thrown-getter=getter\n\ + thrown-callback=callback\n\ + moving-proof-before=63\n\ + moving=20;21;22;\n\ + moving-proof-after=63\n"; + for moving_gc in [false, true] { + let output = run(&bin, dir.path(), moving_gc); + assert_success(&output); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + expected, + "semantic output diverged with moving_gc={moving_gc}" + ); + } +} diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 4114a6348f..a94d82f969 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -18,6 +18,8 @@ "crates/perry-codegen/src/lower_call/scalar_method.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs|8 + crate::target_layout::object_header_size_bytes( ) + 8 * slots;": 1, "crates/perry-codegen/src/stmt/loops.rs|let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, + "crates/perry-codegen/src/stmt/stable_packed_loop.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, + "crates/perry-codegen/src/stmt/stable_packed_loop.rs|let meta_offset = (crate::target_layout::object_header_size_bytes(ctx.target_triple)": 2, "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 16);": 6, "crates/perry-codegen/src/target_layout.rs|let total = 8 + object_header_size_bytes(triple) + 8 * INLINE_SLOT_FLOOR;": 1, "crates/perry-codegen/src/target_layout.rs|object_header_size_bytes(target_triple) + alloc_field_count * FIELD_SLOT_SIZE_BYTES;": 1, @@ -47,7 +49,7 @@ "crates/perry-runtime/src/object/object_ops.rs|keys_array|declaration|pub(crate) use keys_array::{": 1 }, "summary": { - "codegen_object_header_size_sites": 36, + "codegen_object_header_size_sites": 40, "raw_member_files": 7, "raw_member_sites": { "keys_array": 24