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
3 changes: 3 additions & 0 deletions changelog.d/8068-rs4gc-constant-fold-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Canonicalize construction-time constant folds before RS4GC so textual and native in-process LLVM construction emit identical machine code and compact GC maps while retaining live dynamic roots (#8065).
149 changes: 146 additions & 3 deletions crates/perry-codegen/src/inprocess.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ use inkwell::OptimizationLevel;
/// passing against a pipeline production had stopped using. `mem2reg` is not
/// incidental company: RS4GC tracks `addrspace(1)` **SSA values**, not memory,
/// so a root alloca that survives promotion is a root the collector never sees.
pub(crate) const STATEPOINT_REWRITE_PASSES: &str = "function(mem2reg),rewrite-statepoints-for-gc";
// SCCP—not InstCombine—is before RS4GC deliberately (#8065). Native C-API construction
// folds constants as instructions are built, while whole-module text parsing
// retains the equivalent instruction graph. If RS4GC sees those two shapes
// before canonicalization, their live-root ordering can differ and reach both
// machine code and the compact GC map. The ordinary optimization pipeline is
// too late: statepoints and relocations have already been assigned by then.
// The narrower SCCP preserves dynamic pointer round trips which InstCombine
// can erase, so the positive live-root witness remains visible to RS4GC.
pub(crate) const STATEPOINT_REWRITE_PASSES: &str =
"function(mem2reg,sccp),rewrite-statepoints-for-gc";

/// Test seam (#7502): parse `ll_text`, run [`STATEPOINT_REWRITE_PASSES`] for
/// `effective_target`, and return the rewritten IR.
Expand All @@ -54,6 +63,21 @@ pub(crate) fn statepoint_rewritten_ir(
ll_text: &str,
effective_target: &str,
module_name: &str,
) -> Result<String> {
statepoint_rewritten_ir_with_passes(
ll_text,
effective_target,
module_name,
STATEPOINT_REWRITE_PASSES,
)
}

#[cfg(test)]
fn statepoint_rewritten_ir_with_passes(
ll_text: &str,
effective_target: &str,
module_name: &str,
passes: &str,
) -> Result<String> {
global_init(&[]);
let context = Context::create();
Expand All @@ -77,8 +101,8 @@ pub(crate) fn statepoint_rewritten_ir(
.verify()
.map_err(|e| anyhow!("LLVM verifier rejected pre-statepoint module:\n{}", e))?;
module
.run_passes(STATEPOINT_REWRITE_PASSES, &tm, PassBuilderOptions::create())
.map_err(|e| anyhow!("`{STATEPOINT_REWRITE_PASSES}` failed:\n{}", e))?;
.run_passes(passes, &tm, PassBuilderOptions::create())
.map_err(|e| anyhow!("`{passes}` failed:\n{}", e))?;
module
.verify()
.map_err(|e| anyhow!("LLVM verifier rejected the statepoint module:\n{}", e))?;
Expand Down Expand Up @@ -412,6 +436,125 @@ fn optimize_and_emit(
mod tests {
use super::*;

fn constant_fold_order_fixture(folded: bool) -> String {
let mut ir = String::from(
"declare i64 @may_collect()\n\ndefine i64 @f(i64 %d0, i64 %d1, i64 %d2, i64 %d3, i64 %d4, i64 %d5, i64 %d6, i64 %d7) gc \"statepoint-example\" {\nentry:\n",
);
for i in 0..8 {
ir.push_str(&format!(" %cslot{i} = alloca ptr addrspace(1)\n"));
if folded {
ir.push_str(&format!(
" store ptr addrspace(1) inttoptr (i64 9222246136947933185 to ptr addrspace(1)), ptr %cslot{i}\n"
));
} else {
ir.push_str(&format!(
" %cb{i} = bitcast double 0x7FFC000000000001 to i64\n %cp{i} = inttoptr i64 %cb{i} to ptr addrspace(1)\n store ptr addrspace(1) %cp{i}, ptr %cslot{i}\n"
));
}
}
for i in 0..8 {
ir.push_str(&format!(
" %dslot{i} = alloca ptr addrspace(1)\n %dp{i} = inttoptr i64 %d{i} to ptr addrspace(1)\n store ptr addrspace(1) %dp{i}, ptr %dslot{i}\n"
));
}
ir.push_str(" %sp = call i64 @may_collect()\n");
for i in 0..8 {
ir.push_str(&format!(
" %after{i} = load ptr addrspace(1), ptr %dslot{i}\n %bits{i} = ptrtoint ptr addrspace(1) %after{i} to i64\n"
));
}
for i in 0..8 {
ir.push_str(&format!(
" %cafter{i} = load ptr addrspace(1), ptr %cslot{i}\n %cbits{i} = ptrtoint ptr addrspace(1) %cafter{i} to i64\n"
));
}
ir.push_str(" %x1 = xor i64 %bits0, %bits1\n");
for i in 2..8 {
ir.push_str(&format!(" %x{i} = xor i64 %x{}, %bits{i}\n", i - 1));
}
ir.push_str(" %y0 = xor i64 %x7, %cbits0\n");
for i in 1..8 {
ir.push_str(&format!(" %y{i} = xor i64 %y{}, %cbits{i}\n", i - 1));
}
ir.push_str(" ret i64 %y7\n}\n");
ir
}

#[test]
fn rs4gc_canonicalizes_construction_time_folds_before_root_liveness() {
let _native = crate::codegen::helpers::NativeRootsPin::native();
let target = crate::codegen::default_target_triple();
let text_ir = constant_fold_order_fixture(false);
let folded_ir = constant_fold_order_fixture(true);

for (label, ir) in [("text", &text_ir), ("folded", &folded_ir)] {
let rewritten = statepoint_rewritten_ir(ir, &target, label)
.unwrap_or_else(|e| panic!("{label} fixture must run RS4GC: {e:#}"));
assert!(
!rewritten.contains("%cb0 = bitcast"),
"{label} fixture reached RS4GC before construction-time folds converged:\n{rewritten}"
);
let live_bundle = rewritten
.lines()
.find(|line| line.contains("\"gc-live\""))
.unwrap_or_else(|| panic!("{label} fixture lost every dynamic root:\n{rewritten}"));
assert!(
live_bundle.contains("%dp0"),
"{label} fixture lost every dynamic root:\n{rewritten}"
);
assert!(
rewritten.contains("gc.relocate"),
"{label} fixture did not relocate a dynamic root:\n{rewritten}"
);
}

let emit = |ir: &str, name: &str| {
let context = Context::create();
let module = parse_ir_text(&context, ir, name).expect("fixture parses");
optimize_and_emit_module(&module, &target, &["-O3".into(), "-S".into()])
.expect("fixture emits assembly")
};
let text = emit(&text_ir, "constant_fold_text");
let folded = emit(&folded_ir, "constant_fold_native");
assert_eq!(
text, folded,
"construction-time constant folding must converge before RS4GC assigns root liveness"
);

const PRE_FIX_PASSES: &str = "function(mem2reg),rewrite-statepoints-for-gc";
let pre_fix_emit = |ir: &str, name: &str| {
let rewritten = statepoint_rewritten_ir_with_passes(
ir,
&target,
&format!("{name}_rewrite"),
PRE_FIX_PASSES,
)
.expect("pre-fix pipeline rewrites fixture");
let context = Context::create();
let module =
parse_ir_text(&context, &rewritten, name).expect("rewritten fixture parses");
let _shadow = crate::codegen::helpers::NativeRootsPin::shadow();
(
rewritten,
optimize_and_emit_module(&module, &target, &["-O3".into(), "-S".into()])
.expect("rewritten fixture emits assembly"),
)
};
let (pre_fix_text_ir, pre_fix_text) = pre_fix_emit(&text_ir, "pre_fix_text");
let (_, pre_fix_folded) = pre_fix_emit(&folded_ir, "pre_fix_native");
assert!(
pre_fix_text_ir
.lines()
.find(|line| line.contains("\"gc-live\""))
.is_some_and(|line| line.contains("%cp0")),
"negative control must keep a constant-derived text root live across the safepoint:\n{pre_fix_text_ir}"
);
assert_ne!(
pre_fix_text, pre_fix_folded,
"fixture must fail byte equality under the pre-#8065 pass order"
);
}

/// Layer-2 readiness (#7174, engine-plan layer 0 -> 2): the in-process
/// pipeline can schedule `RewriteStatepointsForGC` at the pinned LLVM —
/// no `opt` subprocess, no version-skewed toolchain. This is the exact
Expand Down
85 changes: 72 additions & 13 deletions crates/perry-codegen/src/native_emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,35 +576,92 @@ fn debug_dump(module: &Module<'_>, module_prefix: &str) {
mod tests {
use super::*;
use crate::module::LlModule;
use crate::types::{I32, I64, PTR, VOID};
use crate::types::{I1, I32, I64, PTR, VOID};

fn precise_root_fixture(extra_plain_function: bool) -> LlModule {
let mut module = LlModule::new(crate::codegen::default_target_triple());
module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]);
module.declare_function("js_map_alloc", I64, &[I32]);
module.declare_function("may_collect", I64, &[]);

let function = module.define_function("native_root_diff_fixture", I64, vec![]);
function.enable_shadow_frame(0);
let root_index = function
.reserve_shadow_slot()
.expect("native root fixture reserves one precise-root slot");
let root = function.alloca_entry(I64);
function.entry_allocas_push_store(I64, "0", &root);
function.entry_setup_call_void(
"js_shadow_slot_bind",
&[(I32, &root_index.to_string()), (PTR, &root)],
);
let mut constant_roots = Vec::new();
let mut dynamic_roots = Vec::new();
for roots in [&mut constant_roots, &mut dynamic_roots] {
for _ in 0..8 {
let root_index = function
.reserve_shadow_slot()
.expect("native root fixture reserves a precise-root slot");
let root = function.alloca_entry(I64);
function.entry_allocas_push_store(I64, "0", &root);
function.entry_setup_call_void(
"js_shadow_slot_bind",
&[(I32, &root_index.to_string()), (PTR, &root)],
);
roots.push(root);
}
}
let entry = function.create_block("entry");
let value = entry.call(I64, "js_map_alloc", &[(I32, "0")]);
entry.store(I64, &value, &root);
entry.ret(I64, &value);
// The C-API builder folds this select while whole-module textual IR
// retains it until SCCP. Both shapes must converge BEFORE
// RS4GC decides which SSA roots cross the safepoint (#8065).
for root in &constant_roots {
let constant = entry.select(
I1,
"false",
I64,
"9222246136947933188",
"9222246136947933185",
);
entry.store(I64, &constant, root);
}
for root in &dynamic_roots {
let dynamic = entry.call(I64, "js_map_alloc", &[(I32, "0")]);
entry.store(I64, &dynamic, root);
}
let _safepoint = entry.call(I64, "may_collect", &[]);
// Both values stay live across may_collect. The dynamic one is the
// positive witness: pre-RS4GC canonicalization must not erase it.
let mut observed = entry.load(I64, &dynamic_roots[0]);
for root in constant_roots.iter().chain(dynamic_roots.iter().skip(1)) {
let value = entry.load(I64, root);
observed = entry.xor(I64, &observed, &value);
}
entry.ret(I64, &observed);
if extra_plain_function {
let plain = module.define_function("native_root_diff_plain", VOID, vec![]);
plain.create_block("entry").ret_void();
}
module
}

fn assert_dynamic_root_survives_rs4gc(module: &LlModule, label: &str) {
let target = crate::codegen::default_target_triple();
let text_ir = module.to_ir();
let context = Context::create();
let native_ir = build_native_module(&context, module)
.expect("native root witness constructs")
.print_to_string()
.to_string();
for (arm, ir) in [("text", text_ir), ("native", native_ir)] {
let rewritten = crate::inprocess::statepoint_rewritten_ir(
&ir,
&target,
&format!("{label}_{arm}_root_witness"),
)
.unwrap_or_else(|e| panic!("{arm} root witness must run RS4GC: {e:#}"));
assert!(
rewritten.contains("\"gc-live\"(ptr addrspace(1)"),
"{arm} arm lost the positive dynamic root before RS4GC:\n{rewritten}"
);
assert!(
rewritten.contains("gc.relocate"),
"{arm} arm did not relocate the live dynamic root:\n{rewritten}"
);
}
}

#[test]
fn native_construction_lowers_precise_roots_before_rs4gc() {
let _native = crate::codegen::helpers::NativeRootsPin::native();
Expand All @@ -619,6 +676,7 @@ mod tests {
!text_ir.contains("call void @js_shadow_slot_bind"),
"native-root lowering must consume the shadow-stack bind:\n{text_ir}"
);
assert_dynamic_root_survives_rs4gc(&module, "direct");

let text = crate::linker::compile_ll_to_object(&text_ir, None)
.expect("trusted text arm emits an object");
Expand All @@ -635,6 +693,7 @@ mod tests {
fn split_native_construction_lowers_precise_roots_before_rs4gc() {
let _native = crate::codegen::helpers::NativeRootsPin::native();
let text_module = precise_root_fixture(true);
assert_dynamic_root_survives_rs4gc(&text_module, "split");
let units = text_module.render_codegen_units(2);
assert_eq!(units.len(), 2, "fixture must exercise two real units");
let text = crate::linker::compile_units_to_object(&units, None)
Expand Down
Loading