diff --git a/changelog.d/8068-rs4gc-constant-fold-order.md b/changelog.d/8068-rs4gc-constant-fold-order.md new file mode 100644 index 0000000000..2c3b598bb4 --- /dev/null +++ b/changelog.d/8068-rs4gc-constant-fold-order.md @@ -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). diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index c914ef58d8..0028015ec6 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -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. @@ -54,6 +63,21 @@ pub(crate) fn statepoint_rewritten_ir( ll_text: &str, effective_target: &str, module_name: &str, +) -> Result { + 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 { global_init(&[]); let context = Context::create(); @@ -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))?; @@ -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 diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index c43c17477a..a36ba653ef 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -576,28 +576,59 @@ 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(); @@ -605,6 +636,32 @@ mod tests { 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(); @@ -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"); @@ -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)