Skip to content
Closed
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
5 changes: 5 additions & 0 deletions changelog.d/9782-specialized-typedarray-lifetime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Keep a typed array alive through a specialized function call even when preparing
that call is the caller's last use of the array. The raw-pointer calling
convention still avoids repeated type checks, while native GC roots retain the
owner until the callee returns. This fixes collected typed-array storage and
incorrect checksums in all five full-collection representation stress arms.
17 changes: 17 additions & 0 deletions crates/perry-codegen/src/lower_call/func_ref.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ fn try_emit_spec_static_call(
raw_args_storage
}

// TaPtr entries hoist raw header/data pointers and rely on caller roots.
// The caller's binding can otherwise die at its last use while preparing
// this call: native GC liveness follows SSA uses, not lexical scope. Keep
// the boxed owner live through the call, even when no later JS read exists.
fn keep_ta_owners_alive(ctx: &mut FnCtx<'_>, raw_plan: &[RawArg], lowered: &[String]) {
for entry in raw_plan {
if let RawArg::TaPtr(i) = entry {
let bits = ctx.block().bitcast_double_to_i64(&lowered[*i]);
ctx.block().emit_raw(format!(
"call void asm sideeffect \"\", \"r\"(i64 {bits}) \"gc-leaf-function\""
));
}
}
}

let check_descriptors = matches!(plan.dispatch, crate::codegen::SpecDispatch::Static);
if !range_checked.is_empty() || (check_descriptors && plan.guards.iter().any(Option::is_some)) {
// One diamond for the whole call: every range-checked slot's test is
Expand Down Expand Up @@ -206,6 +221,7 @@ fn try_emit_spec_static_call(
.map(|(ty, v)| (*ty, v.as_str()))
.collect();
let fast_value = ctx.block().call(DOUBLE, &spec_name, &call_args);
keep_ta_owners_alive(ctx, &raw_plan, lowered);
let after_fast = ctx.block().label.clone();
if !ctx.block().is_terminated() {
ctx.block().br(&merge_label);
Expand Down Expand Up @@ -263,6 +279,7 @@ fn try_emit_spec_static_call(
.map(|(ty, v)| (*ty, v.as_str()))
.collect();
let result = ctx.block().call(DOUBLE, &spec_name, &call_args);
keep_ta_owners_alive(ctx, &raw_plan, lowered);
ctx.record_lowered_value(
"Call",
None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,13 @@ fn no_root_alloca_survives_the_statepoint_rewrite() {
);
}
}

#[test]
fn the_statepoint_parser_accepts_quoted_specialized_callees() {
let name = format!("perry_fn_probe${}", "spec_ta4x256");
let fixture = FIXTURE.replace("@js_array_alloc", &format!("@\"{name}\""));
let line = fixture.lines().find(|line| line.contains(&name)).unwrap();
let point = super::parse_statepoint(line);
assert_eq!(point.callee, name);
assert_eq!(point.live, vec!["%a", "%b"]);
}
8 changes: 6 additions & 2 deletions crates/perry-codegen/src/native_root_coverage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ use perry_hir::{Expr, Function, Module, ModuleInitKind, Param, Stmt};

mod harness_self_tests;
mod mechanics;
mod specialized_calls;

/// The two targets native roots ship on, one per object format and
/// architecture. Pinned rather than host-derived — see the module docs.
Expand Down Expand Up @@ -508,8 +509,11 @@ fn callee_after_elementtype(line: &str) -> Option<String> {
let group = group_after(line, "elementtype(")?;
let after =
line[line.find("elementtype(")? + "elementtype(".len() + group.len() + 1..].trim_start();
let name: String = after
.strip_prefix('@')?
// LLVM quotes names containing the specialized-entry separator `$`.
let after_at = after.strip_prefix('@')?;
let name: String = after_at
.strip_prefix('"')
.unwrap_or(after_at)
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$'))
.collect();
Expand Down
101 changes: 101 additions & 0 deletions crates/perry-codegen/src/native_root_coverage/specialized_calls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! #9782: a raw typed-array argument still needs its boxed owner alive in
//! the caller while the specialized callee executes.

use super::*;

#[test]
fn a_last_use_typed_array_is_live_across_the_specialized_call() {
let mut module = bare_module("last_use_typed_array.ts");
module.functions.push(Function {
id: 1,
name: "consume".to_string(),
type_params: Vec::new(),
params: vec![Param {
id: 10,
name: "array".to_string(),
ty: Type::Any,
default: None,
decorators: Vec::new(),
is_rest: false,
arguments_object: None,
}],
return_type: Type::Number,
body: vec![
Stmt::Expr(Expr::MapNew),
Stmt::Return(Some(Expr::IndexGet {
object: Box::new(Expr::LocalGet(10)),
index: Box::new(Expr::Integer(0)),
})),
],
is_async: false,
is_generator: false,
is_strict: true,
is_exported: false,
captures: Vec::new(),
decorators: Vec::new(),
was_plain_async: false,
was_unrolled: false,
});
module.init = vec![
let_stmt(
20,
"owner",
Expr::TypedArrayNew {
kind: perry_hir::TYPED_ARRAY_KIND_INT32,
arg: Some(Box::new(Expr::Integer(256))),
},
),
Stmt::Expr(Expr::Call {
callee: Box::new(Expr::FuncRef(1)),
args: vec![Expr::LocalGet(20)],
type_args: Vec::new(),
byte_offset: 0,
}),
];

for target in NATIVE_TARGETS {
let ir = native_ir(&module, target, true);
let prefix = format!("perry_fn_last_use_typed_array_ts__consume${}", "spec_");
let specialized = ir
.lines()
.filter(|line| line.starts_with("define "))
.filter_map(|line| line.split_once('@')?.1.split_once('(').map(|p| p.0))
.find(|name| name.starts_with(&prefix))
.expect("fixture must select a raw typed-array entry");
let points = statepoints_of(&ir, target, "main");
for point in points.at(specialized) {
assert!(
!point.live.is_empty(),
"[{target}] raw argument lost its owner: {point:?}"
);
}

// Move the lifetime use (and its reloads) above the raw call. Merely
// deleting the asm leaves dead post-call SSA uses that RS4GC still
// considers live before the later dead-code elimination pass.
let mut lines: Vec<_> = ir.lines().collect();
let call = lines
.iter()
.position(|line| line.contains(&format!("call double @{specialized}(")))
.expect("fixture must call its specialized entry");
let end = lines
.iter()
.enumerate()
.skip(call + 1)
.find_map(|(i, line)| {
line.contains("call void asm sideeffect \"\", \"r\"(i64")
.then_some(i)
})
.expect("post-call lifetime use must exist");
let raw_call = lines.remove(call);
lines.insert(end, raw_call);
let broken = lines.join("\n");
let broken_points = statepoints_of(&broken, target, "main");
for point in broken_points.at(specialized) {
assert!(
point.live.is_empty(),
"[{target}] control still roots the owner: {point:?}"
);
}
}
}
22 changes: 22 additions & 0 deletions test-files/test_gap_9782_specialized_typedarray_last_use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// A specialized callee hoists the typed-array data pointer. Its caller must
// retain the array even when the call is the binding's last source-level use.
// Also run with PERRY_GC_HEAP_LIMIT=8 PERRY_GEN_GC=0 to force full collections.
let sink: any[] = [];
function sumDuringChurn(buf: Int32Array, count: number): number {
let sum = 0;
for (let i = 0; i < count; i++) {
sink.push({ i, text: "churn-" + i, pair: [i, i + 1] });
if (sink.length > 4096) sink = [];
sum = (sum + buf[i & 255]) | 0;
}
return sum;
}
function localOwner(): number {
const local = new Int32Array(256);
for (let i = 0; i < 256; i++) local[i] = i;
return sumDuringChurn(local, 320000);
}
const top = new Int32Array(256);
for (let i = 0; i < 256; i++) top[i] = i;
console.log("module-last-use", sumDuringChurn(top, 320000));
console.log("local-last-use", localOwner());
Loading