diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 94c59c4277..546c8e7b0f 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -385,7 +385,9 @@ jobs: native-roots-rs4gc-aarch64: runs-on: macos-14 - timeout-minutes: 90 + # 120, not 90: the in-process step below builds a second time with the + # llvm-inprocess feature, which cargo cannot share with the build above. + timeout-minutes: 120 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -464,6 +466,61 @@ jobs: python3 scripts/statepoint_report_assert.py /tmp/rs4gc-report.json \ --only-backend rs4gc + # #7327. Everything above pins PERRY_LLVM_OPT + PERRY_LLVM_CLANG to one + # brew install, because RS4GC piped IR through an external `opt` and a + # newer `opt` emits attributes an older `clang` cannot parse. That made + # RS4GC reachable only on a hand-pinned toolchain -- and RS4GC is the only + # backend that can root an `invoke`, i.e. every call inside a `try`. + # + # The in-process backend runs the pass at the pinned LLVM with no IR + # crossing a toolchain boundary, so the pinning is no longer needed. This + # step asserts exactly that, and it is the one arm that must run with the + # PERRY_LLVM_* variables UNSET -- otherwise it proves nothing the steps + # above have not already proven. + - name: RS4GC works on a stock toolchain via the in-process backend + if: ${{ !cancelled() }} + run: | + set -euo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + unset PERRY_LLVM_OPT PERRY_LLVM_CLANG + export LLVM_SYS_221_PREFIX="$(brew --prefix llvm)" + export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes" + cargo build --profile perry-dev -p perry -p perry-runtime-static \ + -p perry-stdlib-static --features perry-codegen/llvm-inprocess + + # Probe 09 is the whole point: it carries `try`, so every call in it + # is an `invoke`, which the explicit bridge refuses outright (#7330). + probe=benchmarks/gc_ratchet/probes/09_try_catch_roots.ts + PERRY_RS4GC=1 PERRY_LLVM_INPROCESS=1 \ + ./target/perry-dev/perry "$probe" -o /tmp/inproc-09 + + otool -l /tmp/inproc-09 | grep -q "sectname __perry_gcmap" \ + || { echo "::error::no __perry_gcmap — the in-process route produced no native root map"; exit 1; } + otool -l /tmp/inproc-09 | grep -q "sectname __llvm_stackmaps" \ + && { echo "::error::__llvm_stackmaps survived — the compact rewrite did not run on the in-process path"; exit 1; } + + # Same answer as the shadow stack, and a collection that actually + # moved something. Without the movement assert this passes with the + # conservative scan doing all the rooting (#7336, #7338). + ./target/perry-dev/perry "$probe" -o /tmp/inproc-09-control + PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ + /tmp/inproc-09-control > /tmp/inproc-09.control.out 2>/dev/null + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ + /tmp/inproc-09 > /tmp/inproc-09.out 2> /tmp/inproc-09.err + diff /tmp/inproc-09.control.out /tmp/inproc-09.out \ + || { echo "::error::in-process RS4GC diverged from the shadow-stack control"; exit 1; } + python3 scripts/gc_evacuation_liveness_assert.py /tmp/inproc-09.err + + # And it must be RS4GC doing the lowering, not a per-function bail to + # the bridge -- which would make this arm green while testing the + # backend it is not named after. + PERRY_RS4GC=1 PERRY_LLVM_INPROCESS=1 ./target/perry-dev/perry "$probe" \ + -o /tmp/inproc-09-report --statepoint-report=json 2> /tmp/inproc-09-report.json + python3 scripts/statepoint_report_assert.py /tmp/inproc-09-report.json \ + --only-backend rs4gc + # The x86-64 gap, asserted rather than left as folklore. Statepoints do not # compile on x86-64 Linux today — the compact-map rewriter refuses, which is # the fail-closed path doing its job. This job pins that refusal so it stays a diff --git a/changelog.d/7339-rs4gc-in-process.md b/changelog.d/7339-rs4gc-in-process.md new file mode 100644 index 0000000000..73a1c16a21 --- /dev/null +++ b/changelog.d/7339-rs4gc-in-process.md @@ -0,0 +1,34 @@ +### Fixed + +- **`PERRY_RS4GC=1` now works on a stock toolchain.** RS4GC ran as an external + `opt` subprocess whose output was handed to `clang`; on a Mac that pairing is + Homebrew LLVM 22 feeding Apple clang 21, and the newer `opt` emits attributes + the older `clang` rejects (`error: unterminated attribute group`). The knob was + reachable only with `PERRY_LLVM_CLANG` pointed at a version-matched LLVM 22. + + That was load-bearing rather than cosmetic: RS4GC is the only native-root + backend that can root an `invoke`, and since #7302 every call inside a `try` is + an invoke — the explicit bridge refuses them (#7330), and 26% of the gap suite + contains `try {}`. + + The pass now runs in-process (#7301), where LLVM 22 is already pinned and no IR + crosses a toolchain boundary. Two gaps had to be closed to get there: the + in-process backend discarded `-S` and returned an object where the statepoint + backends asked for assembly, and nothing assembled the result — #7314's + compact-map rewriter works on assembly text, so the assembly went into a `.o` + and the link failed with `ld: unknown file type`. + + All nine `gc_ratchet` probes now compile with no `PERRY_LLVM_*` pinning, + including `09_try_catch_roots`, which the bridge cannot compile at all. All + nine are byte-identical to the shadow-stack control and copy 5,946–90,275 + objects under `PERRY_CONSERVATIVE_STACK_SCAN=off`. + + No default changes: `llvm-inprocess` is still a non-default cargo feature, and + `PERRY_RS4GC=1` without it still takes the external path and still fails loudly. + +- **`gc-native-roots` gained the arm that can observe the above.** Every existing + RS4GC step pins `PERRY_LLVM_OPT` and `PERRY_LLVM_CLANG`, so none of them could + see that the pinning became unnecessary. The new step is the only one that + unsets both, and asserts the map section exists, the compact rewrite ran, a + copying minor actually copied, and RS4GC — not a per-function bail to the + bridge — did the lowering. diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 3bc870e6d6..8a1e4a912f 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -81,7 +81,7 @@ pub fn compile_ll_to_object_inprocess( clang_style_args: &[String], module_name: &str, ) -> Result> { - let (opt, mcpu_native, explicit_cpu, mllvm) = interpret_plan_args(clang_style_args)?; + let (opt, mcpu_native, explicit_cpu, mllvm, emit_asm) = interpret_plan_args(clang_style_args)?; let context = Context::create(); let module = parse_ir_text(&context, ll_text, module_name)?; optimize_and_emit( @@ -91,17 +91,55 @@ pub fn compile_ll_to_object_inprocess( mcpu_native, explicit_cpu.as_deref(), &mllvm, + emit_asm, ) } +/// The CPU an empty `-mcpu` means for this triple. +/// +/// LLVM's `create_target_machine` with an empty CPU selects `generic`, which on +/// aarch64 is **ARMv8.0**. Clang does not do that: for an Apple arm64 triple it +/// defaults to `apple-m1` (ARMv8.5). The gap is not academic — codegen decides +/// whether to emit `llvm.aarch64.fjcvtzs` (FEAT_JSCVT, ARMv8.3+, the +/// single-instruction ECMAScript `ToInt32`) from the TRIPLE ALONE, in +/// `codegen::helpers::set_jscvt_for_target`, precisely because clang's default +/// for that triple has the feature. Handing the same IR to a `generic` +/// TargetMachine gives `LLVM ERROR: Cannot select: intrinsic +/// %llvm.aarch64.fjcvtzs` and aborts the compile. +/// +/// So this is the second half of a pair: `set_jscvt_for_target` decides what to +/// EMIT from the triple, and this decides what the target can EXECUTE from the +/// same triple. They must agree. If a triple is added to one, add it to the +/// other — a mismatch is a hard abort at `-O`-time, not a silent miscompile, +/// which is the one mercy here. +fn default_cpu_for_triple(triple: &str) -> &'static str { + let is_aarch64 = triple.starts_with("arm64") || triple.starts_with("aarch64"); + let is_apple = triple.contains("apple"); + if is_aarch64 && is_apple { + // Matches clang's default for arm64-apple-*, and is the assumption + // `set_jscvt_for_target` already bakes in for macOS/darwin. + "apple-m1" + } else { + // Every other triple keeps LLVM's portable baseline, which is what the + // clang path gets too when no tuning flag is passed. + "" + } +} + /// Interpret the plan argv. Unknown dash-flags are an error on purpose: /// silently ignoring a flag clang would have honored is how the two /// backends drift apart without anyone noticing. #[allow(clippy::type_complexity)] fn interpret_plan_args( clang_style_args: &[String], -) -> Result<(char, bool, Option, Vec)> { +) -> Result<(char, bool, Option, Vec, bool)> { let mut opt = '0'; + // `-S` asks for assembly rather than an object. The statepoint backends + // need it: #7314's compact-map rewriter rewrites `.llvm_stackmaps` at + // ASSEMBLY time, where LLVM prints function addresses as symbol names, so + // one text parser replaces Mach-O and ELF relocation parsing plus a second + // link pass. Emitting an object here would skip that rewrite entirely. + let mut emit_asm = false; let mut mcpu_native = false; let mut explicit_cpu: Option = None; let mut mllvm: Vec = Vec::new(); @@ -111,6 +149,7 @@ fn interpret_plan_args( // `-g` is a measured no-op on Perry IR (no DI metadata; see the // TEMP_NONCE_COUNTER doc block in linker.rs), matching clang. "-c" | "-fno-math-errno" | "-g" => {} + "-S" => emit_asm = true, "-o" | "-target" => { it.next(); } @@ -132,7 +171,7 @@ fn interpret_plan_args( } } } - Ok((opt, mcpu_native, explicit_cpu, mllvm)) + Ok((opt, mcpu_native, explicit_cpu, mllvm, emit_asm)) } /// Parse IR text into a module in `context`. Shared by the transport path @@ -162,7 +201,7 @@ pub(crate) fn optimize_and_emit_module( effective_target: &str, clang_style_args: &[String], ) -> Result> { - let (opt, mcpu_native, explicit_cpu, mllvm) = interpret_plan_args(clang_style_args)?; + let (opt, mcpu_native, explicit_cpu, mllvm, emit_asm) = interpret_plan_args(clang_style_args)?; optimize_and_emit( module, effective_target, @@ -170,6 +209,7 @@ pub(crate) fn optimize_and_emit_module( mcpu_native, explicit_cpu.as_deref(), &mllvm, + emit_asm, ) } @@ -180,6 +220,7 @@ fn optimize_and_emit( mcpu_native: bool, explicit_cpu: Option<&str>, mllvm: &[String], + emit_asm: bool, ) -> Result> { global_init(mllvm); announce(); @@ -203,7 +244,10 @@ fn optimize_and_emit( } else if let Some(cpu) = explicit_cpu { (cpu.to_string(), String::new()) } else { - (String::new(), String::new()) + ( + default_cpu_for_triple(effective_target).to_string(), + String::new(), + ) }; let opt_level = match opt { '0' => OptimizationLevel::None, @@ -228,6 +272,37 @@ fn optimize_and_emit( module.set_triple(&triple); module.set_data_layout(&tm.get_target_data().get_data_layout()); + // RS4GC must run BEFORE the optimization pipeline, and — critically — in + // this process, against this LLVM. + // + // The external path shells `rewrite-statepoints-for-gc` out to an `opt` + // binary and then hands the rewritten IR to `clang -c`. When those are + // different LLVM versions (Homebrew 22 and Apple clang 21 is the ordinary + // macOS case) the emitted IR uses constructs the older parser rejects, and + // the compile dies with `error: unterminated attribute group`. That is why + // RS4GC needed `PERRY_LLVM_CLANG` pointed at a version-matched toolchain, + // and why it did not work on a stock install at all. + // + // Here the same `TargetMachine` runs the pass and emits the object, so the + // skew cannot exist. This matters beyond convenience: RS4GC is the only + // backend that can root an `invoke`, and since #7302 every call inside a + // `try` is one — 26% of the gap suite (128 of 479 files) contains a `try`, + // which the explicit bridge refuses outright (#7327/#7330). + if crate::codegen::helpers::rs4gc_enabled() { + module + .run_passes( + "function(mem2reg),rewrite-statepoints-for-gc", + &tm, + PassBuilderOptions::create(), + ) + .map_err(|e| { + anyhow!( + "in-process rewrite-statepoints-for-gc failed:\n{}", + e.to_string() + ) + })?; + } + let pipeline = match opt { '0' => "default", '1' => "default", @@ -240,9 +315,14 @@ fn optimize_and_emit( .run_passes(pipeline, &tm, PassBuilderOptions::create()) .map_err(|e| anyhow!("pass pipeline `{pipeline}` failed:\n{}", e.to_string()))?; + let kind = if emit_asm { + FileType::Assembly + } else { + FileType::Object + }; let obj = tm - .write_to_memory_buffer(&module, FileType::Object) - .map_err(|e| anyhow!("object emission failed:\n{}", e.to_string()))?; + .write_to_memory_buffer(&module, kind) + .map_err(|e| anyhow!("{kind:?} emission failed:\n{}", e.to_string()))?; Ok(obj.as_slice().to_vec()) } @@ -301,4 +381,104 @@ entry: ); module.verify().expect("statepoint IR verifies"); } + + /// #7327 CI regression: an empty CPU string makes LLVM pick `generic`, + /// which on aarch64 is ARMv8.0 and has no FEAT_JSCVT — so the + /// `llvm.aarch64.fjcvtzs` that codegen emits for any Apple arm64 triple + /// cannot be selected and the compile aborts. Clang defaults that triple to + /// `apple-m1`, which is the assumption `set_jscvt_for_target` already makes. + /// Reproduced with `PERRY_TARGET_CPU=generic`, which is the path CI took. + #[test] + fn apple_aarch64_defaults_to_a_cpu_with_feat_jscvt() { + for triple in [ + "arm64-apple-macosx", + "arm64-apple-darwin", + "aarch64-apple-darwin", + "arm64-apple-ios", + ] { + assert_eq!( + default_cpu_for_triple(triple), + "apple-m1", + "{triple} must not fall back to LLVM's ARMv8.0 `generic`: codegen \ + emits llvm.aarch64.fjcvtzs for Apple arm64 triples" + ); + } + // Everything else keeps LLVM's portable baseline, matching the clang + // path when no tuning flag is passed. + for triple in [ + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + ] { + assert_eq!(default_cpu_for_triple(triple), "", "{triple}"); + } + } + + /// `-S` used to be swallowed by the catch-all that ignores `-c`, so the + /// statepoint backends asked for assembly and were handed an object. The + /// failure was invisible here and surfaced two steps later as + /// `ld: unknown file type`, because #7314's compact-map rewriter rewrites + /// `.llvm_stackmaps` in assembly *text* and had nothing to rewrite. + #[test] + fn dash_s_requests_assembly_and_dash_c_does_not() { + let (_, _, _, _, emit_asm) = + interpret_plan_args(&["-O2".into(), "-S".into()]).expect("args parse"); + assert!(emit_asm, "-S must request assembly"); + + let (_, _, _, _, emit_asm) = + interpret_plan_args(&["-O2".into(), "-c".into()]).expect("args parse"); + assert!(!emit_asm, "-c must still request an object"); + } + + /// The property the wiring depends on: the same module emitted with + /// `FileType::Assembly` is assembler text carrying a stack-map section, + /// not an object. If this ever silently produced an object again, the + /// compact-map rewrite would find no `.llvm_stackmaps` to shrink and the + /// GC would be reading an empty map — the #7332 shape, a binary that + /// looks correct until a collection frees something live. + #[test] + fn assembly_emission_is_text_not_an_object() { + let context = Context::create(); + let ir = r#" +define i32 @f(i32 %x) { +entry: + %y = add i32 %x, 1 + ret i32 %y +} +"#; + let module = parse_ir_text(&context, ir, "asm_probe").expect("probe parses"); + global_init(&[]); + let triple = TargetMachine::get_default_triple(); + let target = Target::from_triple(&triple).expect("host target"); + let tm = target + .create_target_machine( + &triple, + "", + "", + OptimizationLevel::None, + RelocMode::PIC, + CodeModel::Default, + ) + .expect("target machine"); + + let asm = tm + .write_to_memory_buffer(&module, FileType::Assembly) + .expect("assembly emission"); + let text = String::from_utf8_lossy(asm.as_slice()).to_string(); + assert!( + text.contains(".globl") || text.contains(".global"), + "expected assembler directives, got:\n{}", + &text[..text.len().min(200)] + ); + + let obj = tm + .write_to_memory_buffer(&module, FileType::Object) + .expect("object emission"); + assert_ne!( + asm.as_slice(), + obj.as_slice(), + "assembly and object emission returned identical bytes — `-S` is \ + being ignored somewhere in the emission path" + ); + } } diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index a6355c744d..847bccc422 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -483,6 +483,14 @@ fn maybe_rs4gc_preprocess(ll_text: &str) -> Result> { if !crate::codegen::helpers::rs4gc_enabled() { return Ok(None); } + // The in-process backend runs RS4GC itself, against the same LLVM that + // emits the object (see `inprocess::optimize_and_emit`). Shelling out to a + // separate `opt` here as well would both duplicate the rewrite and + // reintroduce the version skew the in-process path exists to remove. + #[cfg(feature = "llvm-inprocess")] + if inprocess_requested() { + return Ok(None); + } let opt = std::env::var("PERRY_LLVM_OPT") .map(PathBuf::from) .ok() @@ -648,6 +656,46 @@ fn compile_ll_inprocess_in( &plan.clang_args, &module_name, ) { + // The statepoint backends ask for `-S`, because #7314's compact-map + // rewriter rewrites `.llvm_stackmaps` at ASSEMBLY time — that is where + // LLVM prints function addresses as symbol names, so one text parser + // replaces Mach-O and ELF relocation parsing plus a second link pass. + // + // So what came back is assembly, not an object. Write it where the + // clang path would have, run the same rewrite-and-assemble step, and + // return the resulting object. Skipping this wrote assembly text into a + // `.o` and the link died with `ld: unknown file type`. + Ok(bytes) if plan.asm_path.is_some() => { + let asm_path = plan.asm_path.as_ref().expect("checked"); + if let Some(parent) = asm_path.parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(asm_path, &bytes) + .with_context(|| format!("Failed to write {}", asm_path.display()))?; + // `plan.clang` is the literal `(in-process)` placeholder here, so + // resolve a real assembler. Using the system clang for this step is + // sound: the version skew that motivated the in-process backend was + // an *IR* parse failure (`unterminated attribute group`), and by + // this point the IR is gone — what is being assembled is text this + // LLVM just printed, which any contemporary assembler accepts. + let assembler = find_clang().context( + "the statepoint compact-map rewrite needs an assembler to turn \ + the emitted assembly into an object, and no clang was found", + )?; + crate::gc_map::compact_and_assemble( + &assembler, + &plan.effective_target, + asm_path, + &plan.obj_path, + )?; + let obj = fs::read(&plan.obj_path) + .with_context(|| format!("Failed to read assembled {}", plan.obj_path.display()))?; + if !policy.keep { + let _ = fs::remove_file(asm_path); + let _ = fs::remove_file(&plan.obj_path); + } + Ok(obj) + } Ok(bytes) => Ok(bytes), Err(e) => { // Same contract as a failed clang compile: the IR that produced