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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/8618-recursive-arena-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
### Performance

- Thread the stable inline-arena state pointer through directly self-recursive
allocation functions, resolving it once in an ABI-preserving public wrapper
instead of repeating the runtime accessor at every recursion level.

- Against `c7645d19b`, retired instructions fall 3.22% for `interp` and 2.16%
for `iso_miss`. An 11-repeat randomized run on a quiet M1 improves median
wall time by 1.92% and 1.30%, respectively. All 20 corpus outputs remain
byte-identical to Node 26.5.1, and the other 18 instruction deltas stay
between -0.47% and +0.18%.
65 changes: 61 additions & 4 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,37 @@ use super::typed_abi::{
typed_string_function_name, TypedFunctionTrampolineKind, TypedParamRep,
};

/// Internal body name for a self-recursive allocator whose arena-state pointer
/// is threaded through recursive calls (#8591).
pub(crate) fn arena_threaded_function_body_name(public_name: &str) -> String {
format!("{public_name}.__arena")
}

fn emit_public_arena_threaded_wrapper(
llmod: &mut LlModule,
f: &Function,
public_name: &str,
body_name: &str,
) {
let params: Vec<(LlvmType, String)> = f
.params
.iter()
.map(|p| (DOUBLE, format!("%arg{}", p.id)))
.collect();
let mut call_args: Vec<(LlvmType, String)> = params.clone();
let wrapper = llmod.define_function(public_name, DOUBLE, params);
wrapper.force_inline = true;
let entry = wrapper.create_block("entry");
let arena_state = entry.call(PTR, "js_inline_arena_state", &[]);
call_args.push((PTR, arena_state));
let call_args: Vec<(LlvmType, &str)> = call_args
.iter()
.map(|(ty, value)| (*ty, value.as_str()))
.collect();
let value = entry.call(DOUBLE, body_name, &call_args);
entry.ret(DOUBLE, &value);
}

/// Compile the internal typed-f64 clone for a conservatively eligible user
/// function. `compile_function` emits both the public JSValue trampoline and
/// the internal generic fallback body; guarded direct FuncRef sites can still
Expand Down Expand Up @@ -502,7 +533,16 @@ pub(super) fn compile_function(
} else {
None
};
let llvm_name = if let Some(plan) = spec_entry {
let arena_threaded = typed_public_trampoline.is_none()
&& guarded_public_plan.is_none()
&& spec_entry.is_none()
&& !f.is_async
&& !f.is_generator
&& !f.was_plain_async
&& cross_module.arena_threaded_functions.contains(&f.id);
let llvm_name = if arena_threaded {
arena_threaded_function_body_name(&public_llvm_name)
} else if let Some(plan) = spec_entry {
// Spec entries are an ADDITIONAL internal symbol next to the ordinary
// public body — never combined with the typed_abi trampoline scheme
// (mutual exclusion is enforced at plan selection).
Expand All @@ -520,7 +560,7 @@ pub(super) fn compile_function(
// alloca slots keyed by the same HIR LocalId. Specialized entries
// (representation-selection Phase 2) instead type each parameter register
// by its rep — raw i32, raw f64, or raw typed-array header pointer (i64).
let params: Vec<(LlvmType, String)> = match spec_entry {
let mut params: Vec<(LlvmType, String)> = match spec_entry {
Some(plan) => f
.params
.iter()
Expand All @@ -533,11 +573,18 @@ pub(super) fn compile_function(
.map(|p| (DOUBLE, format!("%arg{}", p.id)))
.collect(),
};
if arena_threaded {
params.push((PTR, "%perry_arena_state".to_string()));
}

let ic_base = llmod.ic_counter;
let buffer_alias_base = llmod.buffer_alias_counter;
let lf = llmod.define_function(&llvm_name, DOUBLE, params);
if typed_public_trampoline.is_some() || guarded_public_plan.is_some() || spec_entry.is_some() {
if typed_public_trampoline.is_some()
|| guarded_public_plan.is_some()
|| spec_entry.is_some()
|| arena_threaded
{
lf.linkage = "internal".to_string();
}

Expand Down Expand Up @@ -952,6 +999,14 @@ pub(super) fn compile_function(
std::collections::HashSet::new()
};

let arena_state_slot = if arena_threaded {
let slot = lf.alloca_entry(PTR);
lf.entry_allocas_push_store(PTR, "%perry_arena_state", &slot);
Some(slot)
} else {
None
};

let mut ctx = FnCtx {
func: lf,
module_slug: crate::expr::native_region_slug(strings.module_prefix()),
Expand Down Expand Up @@ -1053,7 +1108,7 @@ pub(super) fn compile_function(
shadow_slot_clears_after_stmt,
shadow_slots_bound: bound_param_slots,
temp_roots: crate::rooting::TempRootPool::default(),
arena_state_slot: None,
arena_state_slot,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down Expand Up @@ -1364,6 +1419,8 @@ pub(super) fn compile_function(
emit_public_typed_function_trampoline(llmod, f, &public_llvm_name, &llvm_name, kind);
} else if let Some(plan) = guarded_public_plan.as_ref() {
emit_public_spec_function_trampoline(llmod, f, &public_llvm_name, &llvm_name, plan);
} else if arena_threaded {
emit_public_arena_threaded_wrapper(llmod, f, &public_llvm_name, &llvm_name);
}
Ok(())
}
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ mod typed_abi_opt_report;
mod unknown_func_tests;

pub(crate) use closure::emit_typed_capture_guard;
pub(crate) use function::arena_threaded_function_body_name;
pub use helpers::resolve_target_triple;
pub(crate) use helpers::{
decide_codegen_units, decide_full_outline_ic, default_target_triple, full_outline_ic_enabled,
Expand Down Expand Up @@ -2157,6 +2158,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
// no call-site cap (the cap prices `inlinehint`'s duplication, which
// the inline bump allocator does not incur), plus direct recursion.
alloc_hot_functions: crate::collectors::collect_alloc_hot_functions(hir),
arena_threaded_functions: crate::collectors::collect_self_recursive_allocators(hir),
clamp3_functions: hir
.functions
.iter()
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1064,4 +1064,8 @@ pub(crate) struct CrossModuleCtx {
/// `inlinehint`, whose cost scales with call sites, and the two must not
/// share an admission rule. See the collector's doc comment.
pub alloc_hot_functions: std::collections::HashSet<u32>,
/// #8591: directly self-recursive functions with at least one `new` site.
/// Their internal body carries an `InlineArenaState` pointer through self
/// calls so recursive activations do not repeat the runtime accessor.
pub arena_threaded_functions: std::collections::HashSet<u32>,
}
21 changes: 21 additions & 0 deletions crates/perry-codegen/src/collectors/hot_callees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,27 @@ pub fn collect_alloc_hot_functions(hir: &Module) -> HashSet<u32> {
hot
}

/// Directly self-recursive top-level functions that also contain at least one
/// `new` site in their own body. These are the functions where threading the
/// stable inline-arena pointer through self calls can remove a runtime lookup
/// at every recursive level.
pub fn collect_self_recursive_allocators(hir: &Module) -> HashSet<u32> {
hir.functions
.iter()
.filter_map(|f| {
let mut calls = HotCalleeScan::default();
walk_stmts(&f.body, false, &mut calls);
if !calls.call_counts.contains_key(&f.id) {
return None;
}

let mut sites = HashMap::new();
count_alloc_sites_in_stmts(&f.body, Some(f.id), &mut sites);
(sites.get(&f.id).copied().unwrap_or(0) != 0).then_some(f.id)
})
.collect()
}

fn record_callee(callee: &Expr, in_loop: bool, scan: &mut HotCalleeScan) {
if let Expr::FuncRef(id) = callee {
*scan.call_counts.entry(*id).or_insert(0) += 1;
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/collectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub(crate) use hir_facts::{
};
pub(crate) use hot_callees::{
collect_alloc_hot_functions, collect_hot_loop_callees, collect_recursion_participants,
collect_self_recursive_allocators,
};
pub(crate) use i32_locals::{
collect_integer_let_ids, collect_localset_ids_in_stmts, is_strictly_i32_bounded_expr,
Expand Down
14 changes: 2 additions & 12 deletions crates/perry-codegen/src/expr/array_literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use perry_hir::Expr;

use super::{
emit_jsvalue_slot_store_on_block, expr_produces_non_pointer_bits_by_construction,
nanbox_pointer_inline, FnCtx,
load_inline_arena_state, nanbox_pointer_inline, FnCtx,
};
use crate::rooting;
use crate::type_analysis::is_numeric_expr;
Expand Down Expand Up @@ -127,21 +127,11 @@ pub(crate) fn lower_array_literal(ctx: &mut FnCtx<'_>, elements: &[Expr]) -> Res
let total_size = GC_HEADER_SIZE + ARRAY_HEADER_SIZE + (n as u64) * ELEMENT_SIZE;
let total_size_str = total_size.to_string();

// Lazy per-function slot for the arena state pointer. Reused for
// `new ClassName()` inline allocs; first one to hit creates it.
let arena_state_slot = if let Some(slot) = ctx.arena_state_slot.clone() {
slot
} else {
let slot = ctx.func.entry_init_call_ptr("js_inline_arena_state");
ctx.arena_state_slot = Some(slot.clone());
slot
};

// Load state + compute bump check. `total_size` is always a
// multiple of 8, every prior alloc rounds offset to 8, and blocks
// start 8-aligned, so no align-up step is needed.
let state_ptr = load_inline_arena_state(ctx);
let blk = ctx.block();
let state_ptr = blk.load(PTR, &arena_state_slot);
let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]);
let offset_val = blk.load(I64, &offset_field_ptr);
let aligned_off = offset_val.clone();
Expand Down
28 changes: 20 additions & 8 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::native_value::{
};
use crate::strings::StringPool;
use crate::type_analysis::{is_bigint_expr, is_bool_expr, is_numeric_expr};
use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8};
use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8, PTR};

// Issue #1098: expr.rs split into expr/ submodules. These are pure
// mechanical moves of self-contained helper clusters out of this file;
Expand Down Expand Up @@ -831,13 +831,9 @@ pub(crate) struct FnCtx<'a> {
/// the trust into a four-instruction runtime tag test instead.
pub declared_only_numeric_locals: std::collections::HashSet<u32>,

/// Cached pointer to this function's `InlineArenaState` slot —
/// allocated lazily on the first `new ClassName()` site that uses
/// the inline bump-allocator path. The slot lives in the function
/// entry block (via `LlFunction::entry_init_call_ptr`) and holds
/// the result of a one-time `js_inline_arena_state()` call. Each
/// subsequent `new` in the function loads from this slot instead
/// of paying a TLS access per allocation.
/// Cached pointer to this function's `InlineArenaState` slot. Ordinary
/// functions populate it with the runtime accessor; arena-threaded
/// recursive bodies seed it from their hidden pointer parameter.
///
/// `None` until the first `new` lowers; thereafter `Some(slot_name)`
/// (e.g. `"%r3"`).
Expand Down Expand Up @@ -1860,6 +1856,22 @@ fn inline_cache_global_name_for_prefix(module_prefix: &str, site_id: u32) -> Str
}
}

/// #8591: return this invocation's cached inline-arena state.
///
/// Recursive allocator bodies may seed the cache from a hidden parameter;
/// every other function lazily emits the ordinary entry accessor when its
/// first inline allocation site is lowered.
pub(crate) fn load_inline_arena_state(ctx: &mut FnCtx<'_>) -> String {
let arena_state_slot = if let Some(slot) = ctx.arena_state_slot.clone() {
slot
} else {
let slot = ctx.func.entry_init_call_ptr("js_inline_arena_state");
ctx.arena_state_slot = Some(slot.clone());
slot
};
ctx.block().load(PTR, &arena_state_slot)
}

#[cfg(test)]
mod inline_cache_name_tests {
use super::inline_cache_global_name_for_prefix;
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-codegen/src/lower_call/alloc_hot_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,32 @@ fn a_self_recursive_function_inlines_its_bump_allocator() {
);
}

/// #8591: the public entry resolves the thread's stable arena state once, and
/// the internal recursive body forwards it through every self call.
#[test]
fn a_self_recursive_allocator_threads_arena_state_through_self_calls() {
assert_inline_new_not_forced();
let mut module = walk_module(true);
module.functions[0].params[0].ty = Type::Any;
module.functions[0].return_type = Type::Any;
let ir = ir_for(module);
assert_eq!(
ir.matches("call ptr @js_inline_arena_state()").count(),
1,
"only the public entry wrapper should resolve arena state:\n{ir}"
);
assert!(
ir.contains("define internal double @perry_fn_alloc_hot_ts__walk.__arena(")
&& ir.contains("ptr %perry_arena_state"),
"the recursive allocator needs a hidden arena-state body parameter:\n{ir}"
);
assert!(
ir.contains("call double @perry_fn_alloc_hot_ts__walk.__arena(double")
&& ir.contains(", ptr %r"),
"the public entry and recursive calls must pass the arena-state pointer:\n{ir}"
);
}

/// #8122: the header prefix must be ONE vector store per allocation, composed
/// ONCE — at module init, into the per-class image global — never two scalar
/// stores whose 40-bit GcHeader constant LLVM rematerialises (`mov` + two
Expand Down Expand Up @@ -389,6 +415,10 @@ fn a_cold_straight_line_function_keeps_the_outlined_allocator() {
!ir.contains(INLINE_SLOW_CALL),
"the inline bump allocator reached a cold site:\n{ir}"
);
assert!(
!ir.contains(".__arena("),
"a non-recursive function paid for an arena-threaded entry wrapper:\n{ir}"
);
assert!(
ir.contains(STAMPED_OUTLINED_CALL)
&& ir.contains(SHAPE_MINT_CALL)
Expand Down
14 changes: 13 additions & 1 deletion crates/perry-codegen/src/lower_call/func_ref.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1411,7 +1411,19 @@ pub fn try_lower_func_ref_call(
);
result
} else {
ctx.block().call(DOUBLE, &fname, &arg_slices)
let arena_body = crate::codegen::arena_threaded_function_body_name(&fname);
if ctx.func.name == arena_body {
let arena_slot = ctx
.arena_state_slot
.clone()
.expect("arena-threaded body must cache its hidden parameter");
let arena_state = ctx.block().load(PTR, &arena_slot);
let mut recursive_args = arg_slices.clone();
recursive_args.push((PTR, &arena_state));
ctx.block().call(DOUBLE, &arena_body, &recursive_args)
} else {
ctx.block().call(DOUBLE, &fname, &arg_slices)
}
};
// #7154: release the argument roots HERE and nowhere earlier.
//
Expand Down
24 changes: 7 additions & 17 deletions crates/perry-codegen/src/lower_call/new_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

use perry_hir::Class;

use crate::expr::FnCtx;
use crate::expr::{load_inline_arena_state, FnCtx};
use crate::types::{I32, I64, I8, PTR};

/// #7469: is the `new` site being lowered inside a **loop body**?
Expand Down Expand Up @@ -243,9 +243,11 @@ fn emit_instance_alloc_inner(
// bump-allocator IR — no function call into the runtime at all on
// the hot path. The runtime exposes a `InlineArenaState` struct
// (data ptr at offset 0, current bump offset at offset 8, current
// block size at offset 16) via `js_inline_arena_state()`. We call
// that ONCE per JS function entry (cached in `arena_state_slot`)
// and then emit a 5-instruction bump check + GcHeader/ObjectHeader
// block size at offset 16) via `js_inline_arena_state()`. Ordinary
// allocation kernels cache that pointer at entry; self-recursive
// allocators receive it from their public wrapper and forward it through
// recursive calls. We then emit a 5-instruction bump check +
// GcHeader/ObjectHeader
// store sequence at every `new ClassName()` site. The slow path
// (block overflow) calls `js_inline_arena_slow_alloc` which syncs
// the inline state back to the underlying arena, allocates a new
Expand Down Expand Up @@ -478,21 +480,9 @@ fn emit_instance_alloc_inner(
let total_size = (GC_HEADER_SIZE + payload_size).next_multiple_of(FIELD_SLOT_SIZE);
let total_size_str = total_size.to_string();

// Lazy: allocate the per-function arena-state slot on the
// first `new` we see. The slot init (`call @js_inline_arena_state`
// + store) lives in the entry block via `entry_init_call_ptr`,
// so it dominates every reachable use.
let arena_state_slot = if let Some(slot) = ctx.arena_state_slot.clone() {
slot
} else {
let slot = ctx.func.entry_init_call_ptr("js_inline_arena_state");
ctx.arena_state_slot = Some(slot.clone());
slot
};

// Inline bump-allocator IR.
let state_ptr = load_inline_arena_state(ctx);
let blk = ctx.block();
let state_ptr = blk.load(PTR, &arena_state_slot);

// offset = state.offset (at byte offset 8 in InlineArenaState).
// The offset is invariant 8-aligned: arena blocks start at offset 0
Expand Down
Loading
Loading