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
40 changes: 40 additions & 0 deletions changelog.d/8203-spec-preserve-none.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
### perf(codegen): recursion-participating specialized clones use LLVM's `preserve_none` calling convention (#8175)

fib40 spent ~45% of wall time on a frame its ~165M leaf invocations never
used: a param-derived value live across a call was materialized into a
callee-saved register in the entry block, which pins the CSR save/restore
there and defeats LLVM shrink-wrapping. `preserve_nonecc` deletes the cause —
with no callee-saved registers there is nothing to pin, so the frame sinks
into the recursive path and the leaf runs frameless (5 instructions on
arm64, 3 on x86-64).

Mechanism: one module-level registry (`LlModule` → `RegCounter`) drives the
define header, the cross-unit declare line, and both call choke points
(`LlBlock::call`'s plain and invoke arms), so a call site can never disagree
with its callee's convention — a mismatch is UB, not a verifier error, and
`spec_preserve_none_tests::assert_preserve_none_consistency` scans rendered
modules for agreement in both directions. The in-process dialect reader
parses the token on define/call/invoke lines and sets the real LLVM
convention (`CallingConv::PreserveNone`) on both the function and each call
site, so text, native, and unit-split backends emit the same machine code.

Scope: only specialized clones that participate in direct recursion (Tarjan
SCC over `FuncRef` call edges, `collect_recursion_participants`) — the
boundary cost of entering a `preserve_none` callee from a normal-CC caller
(~20 CSRs saved once per entry) amortizes only under a recursive tree. Spec
clones are `internal` and direct-call-only by construction
(`spec_abi_symbol_reachability`), so the convention cannot escape a module.
Target-gated off watchOS `arm64_32` and ARM64 Windows, the same predicate
family as the RS4GC target-awareness. `PERRY_SPEC_PRESERVE_NONE=0` is the
single-binary A/B kill switch, keyed into the build and object caches.

Liveness gates: the recursive-fixture test counts convention-carrying call
sites (≥3: init site + both recursive edges), and an asm-level test runs the
exact in-process pipeline (RS4GC + `-O3` + target machine) and asserts the
clone's first instruction is not a frame store — a change that silently
stops applying the convention re-grows the pinned frame and goes red.

Measured on the quiet M1 mini (best-of-5, `/usr/bin/time -l`, both arms from
one compiler via the kill switch): per-row instruction + peak-RSS table in
PR #8203. 16 of 19 corpus rows compile byte-identically (the gate holds);
fib40/interp/iso_miss differ.
54 changes: 50 additions & 4 deletions crates/perry-codegen/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ pub struct RegCounter {
/// to find the loads that rewrite makes stale. See
/// `docs/src/internals/gc-rooting-invariant.md`.
shadow_slot_allocas: RefCell<HashSet<String>>,
/// #8175: module-level registry of `preserve_nonecc` function symbols
/// (recursion-participating specialized clones). Injected by
/// `LlModule::define_function` into every function's counter so the two
/// call choke points below can stamp the call-site convention without any
/// per-site threading — a call site whose convention disagrees with its
/// callee is UB, so the registry, not the emitting code, is the single
/// source of truth. `None` for functions built outside a module (tests).
preserve_none_fns: RefCell<Option<Rc<RefCell<HashSet<String>>>>>,
}

impl RegCounter {
Expand All @@ -92,6 +100,28 @@ impl RegCounter {
value: Cell::new(0),
eh_unwind_labels: RefCell::new(Vec::new()),
shadow_slot_allocas: RefCell::new(HashSet::new()),
preserve_none_fns: RefCell::new(None),
}
}

/// Install the module's `preserve_nonecc` symbol registry (#8175). Called
/// once per function by `LlModule::define_function`; the shared cell means
/// registration order does not matter — reads happen at call-emission and
/// render time, both after the specialization plan is final.
pub(crate) fn set_preserve_none_fns(&self, fns: Rc<RefCell<HashSet<String>>>) {
*self.preserve_none_fns.borrow_mut() = Some(fns);
}

/// Whether `callee` must be called with the `preserve_nonecc` convention.
/// Cheap for the overwhelmingly common miss: only generated clone symbols
/// contain `$`, so ordinary runtime helpers never reach the set lookup.
pub(crate) fn callee_preserve_none(&self, callee: &str) -> bool {
if !callee.contains('$') {
return false;
}
match &*self.preserve_none_fns.borrow() {
Some(fns) => fns.borrow().contains(callee),
None => false,
}
}

Expand Down Expand Up @@ -836,6 +866,7 @@ impl LlBlock {
ret: "i32",
callee: "llvm.aarch64.fjcvtzs".to_string(),
args: vec![("double", val.to_string())],
cconv: None,
});
return r;
}
Expand Down Expand Up @@ -1186,16 +1217,24 @@ impl LlBlock {
// codegen finishes to auto-link the providing crate.
crate::ext_registry::record_ffi_call(func_name);
let r = self.reg();
// #8175: a `preserve_nonecc` callee (recursion-participating spec
// clone) must be called with its own convention — on the invoke arm
// exactly as on the plain-call arm, since a mismatch is UB.
let cconv = self
.counter
.callee_preserve_none(func_name)
.then_some(crate::inst::PRESERVE_NONE_CC);
// Invoke-EH (#7302): inside a handler scope, throw-capable calls
// carry the unwind edge. The invoke + inline continuation label ride
// the Raw escape hatch; the native-construction backend bails on
// personality-carrying modules (see codegen/mod.rs) until its line
// reader learns invoke.
if let Some((cont, lpad)) = self.eh_invoke_suffix(func_name) {
let arg_str = format_args(args);
let cc = cconv.map(|c| format!("{c} ")).unwrap_or_default();
self.emit(format!(
"{} = invoke {} @{}({}) to label %{} unwind label %{}",
r, ret_ty, func_name, arg_str, cont, lpad
"{} = invoke {}{} @{}({}) to label %{} unwind label %{}",
r, cc, ret_ty, func_name, arg_str, cont, lpad
));
self.emit_inline_label(&cont);
} else {
Expand All @@ -1204,6 +1243,7 @@ impl LlBlock {
ret: ret_ty,
callee: func_name.to_string(),
args: args.iter().map(|(t, v)| (*t, v.to_string())).collect(),
cconv,
});
}
r
Expand All @@ -1214,11 +1254,16 @@ impl LlBlock {
crate::ext_registry::record_ffi_call(func_name);
self.counter
.note_shadow_slot_bind(func_name, args.get(1).map(|(_, v)| *v));
let cconv = self
.counter
.callee_preserve_none(func_name)
.then_some(crate::inst::PRESERVE_NONE_CC);
if let Some((cont, lpad)) = self.eh_invoke_suffix(func_name) {
let arg_str = format_args(args);
let cc = cconv.map(|c| format!("{c} ")).unwrap_or_default();
self.emit(format!(
"invoke void @{}({}) to label %{} unwind label %{}",
func_name, arg_str, cont, lpad
"invoke {}void @{}({}) to label %{} unwind label %{}",
cc, func_name, arg_str, cont, lpad
));
self.emit_inline_label(&cont);
} else {
Expand All @@ -1227,6 +1272,7 @@ impl LlBlock {
ret: "void",
callee: func_name.to_string(),
args: args.iter().map(|(t, v)| (*t, v.to_string())).collect(),
cconv,
});
}
}
Expand Down
34 changes: 34 additions & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ mod opts;
mod ordinary_param_guard_tests;
mod param_guard;
mod spec_abi;
#[cfg(test)]
mod spec_preserve_none_tests;
mod spec_return_proof;
#[cfg(test)]
mod spec_self_recursion_tests;
Expand Down Expand Up @@ -2730,6 +2732,38 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result<Vec<u8>>
);
}
}

// #8175: recursion-participating specialized clones take LLVM's
// `preserve_none` convention. Registered HERE — after the plan is
// final and before any function body compiles — so every dispatch
// tier (static, guarded/range-checked, the public trampoline's fast
// arm, and the clone's own self-recursion) stamps the call-site
// convention through the one `LlBlock::call` choke point, and the
// clone's define/declare render it from the same registry. Spec
// entries are `internal` and direct-call-only by construction
// (`spec_abi_symbol_reachability`), so the convention cannot escape
// the module. Gated to recursion because the boundary cost is real:
// a normal-CC caller saves ~20 CSRs once per entry, which amortizes
// under a recursive tree and pessimizes a cheap non-recursive callee
// in a hot loop.
if spec_abi::spec_preserve_none_enabled()
&& spec_abi::preserve_none_target_ok(&triple)
&& !cross_module.spec_abi_functions.is_empty()
{
let recursive = crate::collectors::collect_recursion_participants(hir);
let mut preserve_none: Vec<String> = hir
.functions
.iter()
.filter(|f| recursive.contains(&f.id))
.filter_map(|f| {
let plan = cross_module.spec_abi_functions.get(&f.id)?;
let public = func_names.get(&f.id)?;
Some(spec_function_name(public, &plan.reps))
})
.collect();
preserve_none.sort_unstable();
llmod.set_preserve_none_fns(preserve_none);
}
}

progress.checkpoint("locals, closures, and module globals analysis");
Expand Down
60 changes: 59 additions & 1 deletion crates/perry-codegen/src/codegen/spec_abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,49 @@ pub(crate) fn spec_abi_enabled() -> bool {
})
}

/// `PERRY_SPEC_PRESERVE_NONE` gate (#8175). Default on; `0`/`off`/`false`
/// keeps specialized entries on the default C convention.
///
/// This is a BISECTION knob, not a mode: it exists so both arms of an A/B
/// come from ONE compiler binary (flip the guard, not the artifact — the
/// #8175 corpus table's control arm is exactly `=0`), and it is keyed into
/// both the build cache and the object cache so arms never share objects.
/// Kill-policy accounting: the OFF state is not an untested branch — it is
/// the registry-empty configuration that
/// `a_non_recursive_clone_keeps_the_default_convention` and
/// `unsupported_targets_keep_the_default_convention` compile end-to-end in
/// CI, and the parse itself is pinned by `preserve_none_env_parse` below.
pub(crate) fn spec_preserve_none_enabled() -> bool {
use std::sync::OnceLock;
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
preserve_none_env_allows(std::env::var("PERRY_SPEC_PRESERVE_NONE").ok().as_deref())
})
}

/// Pure parse seam for [`spec_preserve_none_enabled`] — the `OnceLock` above
/// caches per process, so the OFF spelling can only be unit-tested here.
fn preserve_none_env_allows(value: Option<&str>) -> bool {
!matches!(value, Some("0") | Some("off") | Some("false"))
}

/// Whether `preserve_nonecc` is usable on this target (#8175).
///
/// Same predicate family as `helpers::set_native_roots_for_target` (the
/// RS4GC target-awareness): aarch64/arm64 and x86-64 have the convention
/// implemented in LLVM's backends; watchOS `arm64_32` (ILP32) and ARM64
/// Windows are excluded — the exact pair whose frame model the rest of the
/// GC stack also refuses. Keep the two predicates' shapes in agreement.
pub(crate) fn preserve_none_target_ok(triple: &str) -> bool {
let arch_ok = (triple.starts_with("aarch64")
|| triple.starts_with("arm64")
|| triple.starts_with("x86_64"))
&& !triple.starts_with("arm64_32");
let windows_ok =
(!triple.contains("windows") && !triple.contains("mingw")) || triple.starts_with("x86_64");
arch_ok && windows_ok
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// `PERRY_SPECIALIZED_ABI_MAX`: module-wide cap on emitted specialized
/// entries (anti-bloat budget). Default 64. Also object-cache-keyed.
pub(crate) fn spec_abi_max() -> usize {
Expand Down Expand Up @@ -255,6 +298,20 @@ mod tests {
);
}

#[test]
fn preserve_none_env_parse() {
use super::preserve_none_env_allows as allows;
// Default ON: unset or any unrecognized spelling.
assert!(allows(None));
assert!(allows(Some("1")));
assert!(allows(Some("on")));
assert!(allows(Some("")));
// The three OFF spellings, same family as PERRY_SPECIALIZED_ABI.
assert!(!allows(Some("0")));
assert!(!allows(Some("off")));
assert!(!allows(Some("false")));
}

#[test]
fn dominant_tuple_selection_demotes_and_counts() {
let ta = SpecParamRep::TaPtr {
Expand Down Expand Up @@ -288,11 +345,12 @@ mod tests {
#[test]
fn spec_abi_symbol_reachability() {
let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let allowed: [&str; 6] = [
let allowed: [&str; 7] = [
"codegen/spec_abi.rs", // naming + this test
"codegen/function.rs", // entry emission
"codegen/mod.rs", // eligibility/budget loop
"codegen/ordinary_param_guard_tests.rs", // structural assertion only
"codegen/spec_preserve_none_tests.rs", // structural assertion only (#8175)
"codegen/spec_self_recursion_tests.rs", // structural assertion only
"lower_call/func_ref.rs", // direct-call dispatch
];
Expand Down
Loading
Loading