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
17 changes: 17 additions & 0 deletions .github/workflows/llvm-inprocess.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ jobs:
# the tracked .ll corpora would otherwise green vacuously.
echo "$out" | grep -q "dialect::tests::corpus_spike ... ok"
echo "$out" | grep -q "dialect::tests::corpus_batch_kernel ... ok"
echo "$out" | grep -q "dialect::tests::corpus_exception_handling ... ok"
echo "$out" | grep -q "inprocess::tests::rs4gc_schedules_in_process ... ok"

- name: Native-mode smoke — liveness, behavior parity, object-byte verdicts
Expand All @@ -109,3 +110,19 @@ jobs:
PERRY_LLVM_INPROCESS=diff PERRY_CODEGEN_UNITS=3 "$BIN" \
benchmarks/app-patterns/kernels/batch.ts -o /tmp/batch_diff 2> /tmp/diffu.err
grep -q "ir-diff. OK.*3 units" /tmp/diffu.err

# #7302: exception handling. try/catch lowers to invoke/landingpad
# with a personality on the define, so a reader that cannot build
# those forms silently loses every try-containing module to the
# textual path — which is exactly how this arm went red once the
# EH migration landed. Assert the EH program takes the native path
# AND behaves identically.
EH=test-files/test_gap_7302_invoke_eh_paths.ts
"$BIN" "$EH" -o /tmp/eh_text
/tmp/eh_text > /tmp/eh_text.out
PERRY_LLVM_INPROCESS=native "$BIN" "$EH" -o /tmp/eh_native 2> /tmp/eh_native.err
grep -q "in-process LLVM backend active" /tmp/eh_native.err
/tmp/eh_native > /tmp/eh_native.out
cmp /tmp/eh_text.out /tmp/eh_native.out
PERRY_LLVM_INPROCESS=diff "$BIN" "$EH" -o /tmp/eh_diff 2> /tmp/eh_diff.err
grep -q "ir-diff. OK" /tmp/eh_diff.err
42 changes: 42 additions & 0 deletions changelog.d/7306-native-backend-invoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
### In-process LLVM backend: construct `invoke`/`landingpad`/personality (#7302 follow-up)

The exception-lowering migration (#7302/#7305) made every `try`-containing
function — and every `async` function, via the rejection boundary — carry a
`personality` clause and `invoke`/`landingpad` instructions. The in-process
LLVM reader (`dialect.rs`, #7301) could not construct those forms, so #7305
routed such modules to the textual path with a module-level bail. That bail
turned the `native-backend` job red: its subject (`spike.ts`) is async, so
native construction never activated and the job's own liveness assertion —
`grep "in-process LLVM backend active"` — correctly failed.

Fixed properly rather than by narrowing the gate:

- `parse_header` lifts the `personality ptr @NAME` clause out of the define's
attribute list and `begin` applies it via `set_personality_function`
(previously the attribute loop hit `personality` and bailed as an unknown
attribute).
- New `invoke` construction (value and void forms), sharing the callsite-typed,
call-through-pointer semantics of the existing `call` path and adding the
normal/unwind edges.
- New `landingpad` construction for the one shape Perry emits — a catch-all
`{ ptr, i32 } catch ptr null` whose result is unused (the thrown value comes
from the runtime's rooted TLS slot). Any other shape bails loudly.
- The module-level bail and its `has_eh_personality` helper are deleted; the
per-function textual path in `native_emit` now feeds the reader rather than
falling back to clang.

Verified locally against LLVM 22.1.4: liveness assertion restored, `spike.ts`
and the 3-unit `batch.ts` arms emit **byte-identical objects** on both paths,
and a full try/catch/finally program compiles through native construction with
output identical to the textual path.

Gate hardening (so this class of regression is caught by the gate that owns
it, not by a downstream merge):

- `dialect::tests::corpus_exception_handling` round-trips a tracked
try/catch/finally corpus (`eh_text.ll`) through native construction and the
LLVM verifier, and asserts the corpus still *contains* invoke edges, a
landing pad, and the personality clause — a corpus that lost its EH forms
would otherwise keep passing while testing nothing.
- The `native-backend` workflow gains an EH arm: liveness + behavior parity +
object-byte diff verdict on a try/catch program, alongside the async spike.
11 changes: 0 additions & 11 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2654,11 +2654,6 @@ fn try_native_units(
target: Option<&str>,
module_prefix: &str,
) -> Option<Result<Vec<u8>>> {
// Invoke-EH (#7302): see try_native_construction — textual path for
// try/catch modules until the native reader learns invoke.
if llmod.has_eh_personality() {
return None;
}
match crate::native_emit::native_mode() {
crate::native_emit::NativeMode::Off => None,
crate::native_emit::NativeMode::Native => Some(
Expand Down Expand Up @@ -2691,12 +2686,6 @@ fn try_native_construction(
target: Option<&str>,
module_prefix: &str,
) -> Option<Result<Vec<u8>>> {
// Invoke-EH (#7302): the native line reader does not know
// `invoke`/`landingpad`/`catchswitch` yet — modules with try/catch take
// the textual path. Follow-up tracked on #7301.
if llmod.has_eh_personality() {
return None;
}
match crate::native_emit::native_mode() {
crate::native_emit::NativeMode::Off => None,
crate::native_emit::NativeMode::Native => Some(crate::native_emit::compile_module_native(
Expand Down
128 changes: 128 additions & 0 deletions crates/perry-codegen/src/dialect/eh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Exception-handling instruction construction for the in-process LLVM
//! reader (#7302): `invoke` edges and `landingpad`.
//!
//! Split out of `dialect/mod.rs` to keep that file under the 2000-line
//! cap; these are the forms Perry's try/catch lowering emits, and they
//! share the parent reader's state (block map, value map, builder).

use anyhow::{anyhow, bail, Result};
use inkwell::values::BasicValueEnum;
use inkwell::AddressSpace;

use super::{
basic_type, be, indirect_fn_type, rmatch_paren, split_top_level, ty_and_val, unquote, FnReader,
};

impl<'ctx, 'm> FnReader<'ctx, 'm> {
/// `[%r =] invoke TY @callee(ARGS) to label %CONT unwind label %PAD`
/// (#7302). Shares the callsite-typed, call-through-pointer semantics
/// of [`call`]; the only additions are the two edges.
pub(super) fn invoke(
&mut self,
dst: Option<&str>,
rest: &str,
) -> Result<Option<BasicValueEnum<'ctx>>> {
let to_pos = rest
.rfind(" to label %")
.ok_or_else(|| anyhow!("invoke without `to label`"))?;
let head = &rest[..to_pos];
let edges = &rest[to_pos + " to label %".len()..];
let (cont_label, unwind_label) = edges
.split_once(" unwind label %")
.ok_or_else(|| anyhow!("invoke without `unwind label`"))?;
let cont = self.block(cont_label.trim());
let pad = self.block(unwind_label.trim());

let callee_pos = head
.find(['@', '%'])
.ok_or_else(|| anyhow!("invoke without callee"))?;
let sig_str = head[..callee_pos].trim().trim_end_matches('*').trim();
let after = &head[callee_pos..];
let paren = after
.find('(')
.ok_or_else(|| anyhow!("invoke missing arg list"))?;
let callee = &after[..paren];
let close = rmatch_paren(after, paren)?;
let args_str = &after[paren + 1..close];

// `build_indirect_invoke` takes basic values (not metadata enums
// like the call path), so collect both shapes once.
let mut args: Vec<BasicValueEnum> = Vec::new();
let mut arg_types: Vec<inkwell::types::BasicMetadataTypeEnum> = Vec::new();
for a in split_top_level(args_str) {
let (aty, atok) = ty_and_val(&a)?;
let ty = basic_type(self.ctx, aty)?;
args.push(self.val(ty, atok)?);
arg_types.push(ty.into());
}

let name = dst.map(|d| d.trim_start_matches('%')).unwrap_or("");
let fn_ty = indirect_fn_type(self.ctx, sig_str, &arg_types)?;
let callee_ptr = if let Some(fname) = callee.strip_prefix('@') {
let n = unquote(fname);
let f = match self.module.get_function(&n) {
Some(f) => f,
None if n.starts_with("llvm.") => self.module.add_function(&n, fn_ty, None),
None => bail!("invoke of undeclared @{n}"),
};
f.as_global_value().as_pointer_value()
} else {
self.val(self.ctx.ptr_type(AddressSpace::default()).into(), callee)?
.into_pointer_value()
};
let site = self
.builder
.build_indirect_invoke(fn_ty, callee_ptr, &args, cont, pad, name)
.map_err(be)?;
// An invoke terminates its block; the emitted text continues in
// the inline continuation label, which arrives as the next line.
match site.try_as_basic_value() {
inkwell::values::ValueKind::Basic(v) => {
if let Some(d) = dst {
self.vals.insert(d.trim().to_string(), v);
}
Ok(Some(v))
}
_ => Ok(None),
}
}
Comment on lines +79 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use self.def() instead of a direct self.vals insert for invoke and landingpad results.

invoke (Line 82) and landingpad (Line 125) both write the destination register directly into self.vals. Every other value-producing instruction in this reader (phi, and the shared tail in value_inst for call, alloca, getelementptr, etc.) resolves its destination through self.def() instead.

self.def() performs a required step: if %dst was already read as a forward reference (via val()'s placeholder path, which allocates a scratch alloca and load), def() replaces that placeholder with the real value and erases the scratch instructions. Bypassing def() skips this step.

If invoke or landingpad's destination register is ever used as a forward reference before this instruction runs (plausible in try/catch/finally control flow, since enter_block already documents that block layout is not guaranteed to match textual order), two failure modes follow:

  • Any earlier use of the register keeps referencing the placeholder's load from an uninitialized scratch alloca instead of the real value, corrupting the constructed IR.
  • finish() still finds the register in self.placeholders and bails with "register {name} was used but never defined", even though the register was in fact defined.

Route both results through self.def().

🐛 Proposed fix
         let site = self
             .builder
             .build_indirect_invoke(fn_ty, callee_ptr, &args, cont, pad, name)
             .map_err(be)?;
         // An invoke terminates its block; the emitted text continues in
         // the inline continuation label, which arrives as the next line.
         match site.try_as_basic_value() {
             inkwell::values::ValueKind::Basic(v) => {
                 if let Some(d) = dst {
-                    self.vals.insert(d.trim().to_string(), v);
+                    self.def(d, v)?;
                 }
                 Ok(Some(v))
             }
             _ => Ok(None),
         }
     }
         let v = self
             .builder
             .build_landing_pad(
                 exc_ty,
                 pf,
                 &[null.into()],
                 false,
                 dst.trim_start_matches('%'),
             )
             .map_err(be)?;
-        self.vals.insert(dst.trim().to_string(), v);
-        Ok(())
+        self.def(dst, v)
     }

Based on learnings, "**/*.rs" guidance for this crate calls for the shadow-slot/root-store dominance rules and warns against a value being resolved "only in an untracked alloca"; the unresolved placeholder scratch alloca here is exactly that pattern.

Also applies to: 115-127

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/dialect/eh.rs` around lines 79 - 88, Route
destination registers for both invoke and landingpad results through self.def()
instead of inserting directly into self.vals. Update the result-handling
branches in the invoke reader and landingpad reader, preserving the existing
handling for destinations, non-basic values, and returned values while ensuring
forward-reference placeholders are resolved and removed.

Source: Coding guidelines


/// `%r = landingpad { ptr, i32 } catch ptr null` (#7302). Perry emits
/// exactly one shape — a catch-all pad whose `{ptr, i32}` result is
/// unused (the thrown value comes from the runtime's rooted TLS slot),
/// so anything else is a dialect drift and bails loudly.
pub(super) fn landingpad(&mut self, dst: &str, rest: &str) -> Result<()> {
let body = rest.trim();
let clause_pos = body
.find("catch")
.ok_or_else(|| anyhow!("landingpad without a catch clause: {body}"))?;
let ty_tok = body[..clause_pos].trim();
if ty_tok.replace(' ', "") != "{ptr,i32}" {
bail!("unexpected landingpad type `{ty_tok}`");
}
if body[clause_pos..].replace(' ', "") != "catchptrnull" {
bail!("unexpected landingpad clauses `{}`", &body[clause_pos..]);
}
let pf = self
.func
.get_personality_function()
.ok_or_else(|| anyhow!("landingpad in a function with no personality"))?;
let ptr_ty = self.ctx.ptr_type(AddressSpace::default());
let exc_ty = self
.ctx
.struct_type(&[ptr_ty.into(), self.ctx.i32_type().into()], false);
let null = ptr_ty.const_null();
let v = self
.builder
.build_landing_pad(
exc_ty,
pf,
&[null.into()],
false,
dst.trim_start_matches('%'),
)
.map_err(be)?;
self.vals.insert(dst.trim().to_string(), v);
Ok(())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ use inkwell::values::{
};
use inkwell::{AddressSpace, FloatPredicate, IntPredicate};

mod eh;
#[cfg(test)]
mod tests;

/// Create (only) the function declaration for `fn_text`'s define header, so
/// later-defined functions are callable while earlier bodies are read. The
/// native path pre-declares every define before reading any body — calls to
Expand Down Expand Up @@ -156,6 +160,9 @@ struct ParsedHeader {
/// (type token, `%name`) per parameter.
params: Vec<(String, String)>,
attr_str: String,
/// `personality ptr @NAME` clause, if the define carries one (#7302:
/// every function containing try/catch does).
personality: Option<String>,
}

fn parse_header(header: &str) -> Result<ParsedHeader> {
Expand Down Expand Up @@ -201,7 +208,25 @@ fn parse_header(header: &str) -> Result<ParsedHeader> {
let name = unquote(&after[..paren]);
let close = rmatch_paren(after, paren)?;
let params_str = &after[paren + 1..close];
let attr_str = after[close + 1..].trim().to_string();
let mut attr_str = after[close + 1..].trim().to_string();
// `personality ptr @NAME` sits with the fn attributes on the define
// line; lift it out so the attribute loop only sees real attributes.
let mut personality = None;
if let Some(pos) = attr_str.find("personality ") {
let tail = attr_str[pos..].to_string();
attr_str = attr_str[..pos].trim_end().to_string();
let pname = tail
.trim_start_matches("personality ")
.trim()
.trim_start_matches("ptr ")
.trim()
.trim_start_matches('@')
.trim();
if pname.is_empty() {
bail!("malformed personality clause: {tail}");
}
personality = Some(unquote(pname));
}

let mut params = Vec::new();
for p in split_top_level(params_str) {
Expand All @@ -220,6 +245,7 @@ fn parse_header(header: &str) -> Result<ParsedHeader> {
name,
params,
attr_str,
personality,
})
}

Expand Down Expand Up @@ -257,6 +283,12 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> {
bail!("duplicate define of @{}", h.name);
}
let param_names: Vec<String> = h.params.iter().map(|(_, n)| n.clone()).collect();
if let Some(pname) = &h.personality {
let pf = module
.get_function(pname)
.ok_or_else(|| anyhow!("personality @{pname} not declared"))?;
func.set_personality_function(pf);
}
let attr_str = h.attr_str;
for a in attr_str.split_whitespace() {
match a {
Expand Down Expand Up @@ -425,6 +457,8 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> {
"call" | "tail call" => self
.call(Some(dst), rest)?
.ok_or_else(|| anyhow!("call with result had void type"))?,
"invoke" => return self.invoke(Some(dst), rest).map(|_| ()),
"landingpad" => return self.landingpad(dst, rest),
"getelementptr" => self.gep(dst, rest)?,
"select" => {
// `select i1 C, T A, T B`
Expand Down Expand Up @@ -506,6 +540,7 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> {
Ok(())
}
"call" | "tail call" => self.call(None, rest).map(|_| ()),
"invoke" => self.invoke(None, rest).map(|_| ()),
"switch" => self.switch(rest),
"unreachable" => {
self.builder.build_unreachable().map_err(be)?;
Expand Down Expand Up @@ -1909,80 +1944,3 @@ fn apply_flags(inst: Option<InstructionValue<'_>>, flags: &[&str]) {
unsafe { llvm_sys::core::LLVMSetFastMathFlags(inst.as_value_ref(), fmf) };
}
}

#[cfg(test)]
mod tests {
use super::*;

fn split_corpus(text: &str) -> (String, Vec<String>) {
let mut skeleton = String::new();
let mut fns = Vec::new();
let mut cur: Option<String> = None;
for line in text.lines() {
if line.starts_with("define ") {
cur = Some(String::new());
}
match cur.as_mut() {
Some(f) => {
f.push_str(line);
f.push('\n');
if line == "}" {
fns.push(cur.take().unwrap());
}
}
None => {
skeleton.push_str(line);
skeleton.push('\n');
}
}
}
(skeleton, fns)
}

/// Every function in a real perry-emitted corpus file must construct
/// natively and pass the LLVM verifier. This is the reader's primary
/// gate: a form it cannot express fails here, not in a user build.
fn corpus_roundtrip(path: &str) {
// The corpora are tracked in-tree alongside this reader, so a missing
// file is a broken checkout, not a branch without artifacts. Skipping
// would make the reader's primary gate pass vacuously — precisely the
// failure mode the Linux bring-up had to rule out by hand.
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("corpus file {path} is not readable: {e}"));
let (skeleton, fns) = split_corpus(&text);
let ctx = Context::create();
let module = crate::inprocess::parse_ir_text(&ctx, &skeleton, "corpus_skel")
.expect("skeleton parses");
for f in &fns {
predeclare_function_from_text(&ctx, &module, f)
.unwrap_or_else(|e| panic!("predeclare: {e:#}"));
}
let mut n = 0usize;
for f in &fns {
n += add_function_from_text(&ctx, &module, f).unwrap_or_else(|e| panic!("{e:#}"));
}
assert!(
n > 1000,
"expected a real corpus, built only {n} instructions"
);
module
.verify()
.unwrap_or_else(|e| panic!("verifier rejected native module:\n{}", e.to_string()));
}

#[test]
fn corpus_spike() {
corpus_roundtrip(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../experiments/llvm-inprocess-spike/spike_text.ll"
));
}

#[test]
fn corpus_batch_kernel() {
corpus_roundtrip(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../experiments/llvm-inprocess-spike/batch_kernel.ll"
));
}
}
Loading
Loading