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
59 changes: 58 additions & 1 deletion .github/workflows/gc-native-roots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions changelog.d/7339-rs4gc-in-process.md
Original file line number Diff line number Diff line change
@@ -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.
194 changes: 187 additions & 7 deletions crates/perry-codegen/src/inprocess.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub fn compile_ll_to_object_inprocess(
clang_style_args: &[String],
module_name: &str,
) -> Result<Vec<u8>> {
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(
Expand All @@ -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<String>, Vec<String>)> {
) -> Result<(char, bool, Option<String>, Vec<String>, 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<String> = None;
let mut mllvm: Vec<String> = Vec::new();
Expand All @@ -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();
}
Expand All @@ -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
Expand Down Expand Up @@ -162,14 +201,15 @@ pub(crate) fn optimize_and_emit_module(
effective_target: &str,
clang_style_args: &[String],
) -> Result<Vec<u8>> {
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,
opt,
mcpu_native,
explicit_cpu.as_deref(),
&mllvm,
emit_asm,
)
}

Expand All @@ -180,6 +220,7 @@ fn optimize_and_emit(
mcpu_native: bool,
explicit_cpu: Option<&str>,
mllvm: &[String],
emit_asm: bool,
) -> Result<Vec<u8>> {
global_init(mllvm);
announce();
Expand All @@ -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,
Expand All @@ -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<O0>",
'1' => "default<O1>",
Expand All @@ -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())
}

Expand Down Expand Up @@ -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"
);
}
}
Loading
Loading