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
60 changes: 59 additions & 1 deletion crates/loon-lang/src/eir/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ use std::path::PathBuf;
pub fn lower(checker: &Checker) -> Module {
let mut ctx = Lower::new(checker);
ctx.lower_program();
ctx.finish()
let mut module = ctx.finish();
// Calls are emitted uniformly as `Op::Call` + a jump to a merge block;
// which of them are in tail position is recognized on the finished IR.
super::tailcall::mark_tail_calls(&mut module);
module
}

// ─── Lowering context ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -2815,6 +2819,60 @@ mod tests {
assert!(module.funcs.len() >= 2); // __main + add
}

/// `[recur ...]` is the one tail construct the lowering does emit: it
/// becomes `End::Recur`, a jump back to block 0, so it costs no frame.
#[test]
fn recur_lowers_to_end_recur() {
let module = lower_src(
r#"
[fn countdown [n] [if [= n 0] :done [recur [- n 1]]]]
[countdown 3]
"#,
);
let countdown = module
.funcs
.iter()
.find(|f| f.name.as_deref() == Some("countdown"))
.expect("countdown should be lowered");
assert!(
countdown
.blocks
.iter()
.any(|b| matches!(b.end, End::Recur(_))),
"recur should lower to End::Recur"
);
}

/// A call in tail position becomes `End::Tail`. The lowering itself still
/// emits every call the same way — `Op::Call` into a register plus a jump
/// to a merge block — and `eir::tailcall` recognizes the tail-position
/// shape afterwards. This test guards the end-to-end result.
#[test]
fn mutual_recursion_lowers_to_a_tail_call() {
let module = lower_src(
r#"
[fn even? [n] [if [= n 0] true [odd? [- n 1]]]]
[fn odd? [n] [if [= n 0] false [even? [- n 1]]]]
[even? 10]
"#,
);
for name in ["even?", "odd?"] {
let f = module
.funcs
.iter()
.find(|f| f.name.as_deref() == Some(name))
.unwrap_or_else(|| panic!("{name} should be lowered"));
assert_eq!(
f.blocks
.iter()
.filter(|b| matches!(b.end, End::Tail(..)))
.count(),
1,
"{name} should end in a tail call"
);
}
}

#[test]
fn lower_if() {
let module = lower_src("[if true 1 2]");
Expand Down
1 change: 1 addition & 0 deletions crates/loon-lang/src/eir/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod lower;
pub mod native;
pub mod net;
pub mod replay;
pub mod tailcall;
pub mod trace;
pub mod value64;
pub mod vm;
Expand Down
228 changes: 212 additions & 16 deletions crates/loon-lang/src/eir/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,26 @@
//! - Arithmetic, comparison, and logic binary ops
//! - Unary ops (neg, not)
//! - Mov, branches, jumps, returns
//! - Function calls (direct)
//! - Function calls (direct), including tail calls (`return_call`)
//! - Builtin println (via extern)
//!
//! Not yet implemented (fall back to VM):
//! - Closures / upvalues / indirect calls
//! - Collection construction (Vec, Map, Set, Tuple, ADT)
//! - Field access, tag extraction
//! - Effect operations (perform, push/pop handler)
//! - Tail calls (compiled as regular calls + return)
//! - String operations
//!
//! Loon functions are compiled with Cranelift's `tail` calling convention so
//! that `End::Tail` can lower to a real `return_call` (constant stack for
//! mutual tail recursion). That convention is not the platform C ABI, so the
//! entry point is reached through a small C-ABI trampoline — see
//! `ENTRY_TRAMPOLINE`.

use cranelift_codegen::ir::condcodes::IntCC;
use cranelift_codegen::ir::types::I64;
use cranelift_codegen::ir::{AbiParam, Function, InstBuilder, Signature, UserFuncName};
use cranelift_codegen::isa::CallConv;
use cranelift_codegen::settings::{self, Configurable};
use cranelift_codegen::Context;
use cranelift_frontend::{FunctionBuilder, FunctionBuilderContext, Variable};
Expand All @@ -50,6 +56,9 @@ const VAL_TRUE: u64 = BASE | TAG_IMM | 1;
const VAL_FALSE: u64 = BASE | TAG_IMM | 2;
const VAL_NONE: u64 = BASE | TAG_IMM | 3;

/// Symbol name of the C-ABI shim that calls the module's entry function.
const ENTRY_TRAMPOLINE: &str = "loon_entry_trampoline";

// ─── Runtime helper functions ───────────────────────────────────────────────

/// Runtime: println a NaN-boxed value. Called from compiled code.
Expand Down Expand Up @@ -172,6 +181,18 @@ impl NativeModule {
phase: "native:setup",
})?;
}
// Required by `CallConv::Tail`, which every Loon function uses so that
// `End::Tail` can lower to `return_call`. Cranelift's x64 tail-call
// emitter asserts on a missing frame pointer ("frame pointers aren't
// fundamentally required for tail calls, but the current
// implementation relies on them being present"); aarch64 maintains one
// unconditionally, so this only bites on x86_64.
flag_builder
.set("preserve_frame_pointers", "true")
.map_err(|e| Error {
message: format!("cranelift flag error: {e}"),
phase: "native:setup",
})?;
let isa_builder = cranelift_native::builder().map_err(|msg| Error {
message: format!("unsupported host: {msg}"),
phase: "native:setup",
Expand All @@ -192,12 +213,16 @@ impl NativeModule {

let mut jit = JITModule::new(builder);

// Create the default calling convention signature: all args and return are i64.
let call_conv = jit.isa().default_call_conv();
// The platform C ABI, used for the runtime helpers (plain Rust
// `extern "C"` functions) and for the entry trampoline.
let c_call_conv = jit.isa().default_call_conv();
// Loon functions use the `tail` convention instead: `return_call`
// requires caller and callee to share a tail-call-capable convention.
let call_conv = CallConv::Tail;

// Declare runtime helper functions.
let rt_println_sig = {
let mut sig = Signature::new(call_conv);
let mut sig = Signature::new(c_call_conv);
sig.params.push(AbiParam::new(I64));
sig.returns.push(AbiParam::new(I64));
sig
Expand Down Expand Up @@ -302,15 +327,53 @@ impl NativeModule {
})?;
}

// Entry trampoline: `execute()` calls the module through a plain C
// function pointer, but the entry itself uses the `tail` convention,
// which is not the C ABI. Bridge the two with a C-ABI shim that calls
// the entry and returns its result.
let entry_cl_id = func_map.funcs[&eir_module.entry.0];
let trampoline_id = {
let mut sig = Signature::new(c_call_conv);
sig.returns.push(AbiParam::new(I64));
let id = jit
.declare_function(ENTRY_TRAMPOLINE, Linkage::Local, &sig)
.map_err(|e| Error {
message: format!("declare {ENTRY_TRAMPOLINE}: {e}"),
phase: "native:declare",
})?;

// Namespace 1: EIR functions occupy namespace 0, indexed by
// FuncId. The trampoline is not an EIR function, so it gets its
// own namespace rather than an index just past the end of theirs.
let mut cl_func = Function::with_name_signature(UserFuncName::user(1, 0), sig);
{
let mut builder = FunctionBuilder::new(&mut cl_func, &mut fb_ctx);
let block = builder.create_block();
builder.switch_to_block(block);
builder.seal_block(block);
let entry_ref = jit.declare_func_in_func(entry_cl_id, builder.func);
let call = builder.ins().call(entry_ref, &[]);
let result = builder.inst_results(call)[0];
builder.ins().return_(&[result]);
builder.finalize();
}
let mut ctx = Context::for_function(cl_func);
jit.define_function(id, &mut ctx).map_err(|e| Error {
message: format!("define {ENTRY_TRAMPOLINE}: {e}"),
phase: "native:codegen",
})?;
id
};

// Finalize all definitions.
jit.finalize_definitions().map_err(|e| Error {
message: format!("finalize: {e}"),
phase: "native:finalize",
})?;

// Get entry function pointer.
let entry_cl_id = func_map.funcs[&eir_module.entry.0];
let entry_fn = jit.get_finalized_function(entry_cl_id);
// Get entry function pointer (the C-ABI trampoline, not the entry
// itself — see above).
let entry_fn = jit.get_finalized_function(trampoline_id);

Ok(NativeModule {
_jit: jit,
Expand Down Expand Up @@ -922,8 +985,9 @@ fn compile_terminator(
}

End::Tail(func_id, args) => {
// Compile tail calls as regular calls + return (no TCO in Cranelift
// for our calling convention yet).
// A real tail call: `return_call` replaces the current frame, so
// mutual tail recursion runs in constant stack. This is why loon
// functions are compiled with `CallConv::Tail`.
let cl_func_id = func_map.funcs.get(&func_id.0).ok_or_else(|| Error {
message: format!("unknown tail call target {}", func_id.0),
phase: "native:compile",
Expand All @@ -933,15 +997,19 @@ fn compile_terminator(
.iter()
.map(|r| builder.use_var(vars[r.0 as usize]))
.collect();
let call = builder.ins().call(func_ref, &arg_vals);
let result = builder.inst_results(call)[0];
builder.ins().return_(&[result]);
builder.ins().return_call(func_ref, &arg_vals);
}

End::TailInvoke(_callee, _args) => {
// Indirect tail calls need closure support.
let unit = builder.ins().iconst(I64, VAL_UNIT as i64);
builder.ins().return_(&[unit]);
// Indirect tail calls need closures, which this backend does not
// represent yet (`Op::Close`/`Op::Invoke` are still stubs). Fail
// loudly rather than returning Unit — a silent wrong answer is far
// worse than a missing feature.
return Err(Error {
message: "tail call to a closure is not supported by the native backend yet"
.to_string(),
phase: "native:compile",
});
}

End::Recur(args) => {
Expand Down Expand Up @@ -1087,6 +1155,134 @@ mod tests {
assert_eq!(result.as_int(), 120);
}

/// Mutual tail recursion from source must run in constant stack. Compiled
/// as a plain call + return this recurses a million frames deep and
/// overflows, so reaching the assert is the evidence that `End::Tail` is
/// both emitted by the lowering and lowered to `return_call` here.
#[test]
fn tail_calls_run_in_constant_stack_from_source() {
let src = r#"
[fn even? [n] [if [= n 0] true [odd? [- n 1]]]]
[fn odd? [n] [if [= n 0] false [even? [- n 1]]]]
[even? 1000000]
"#;
assert_eq!(eval_native(src).unwrap(), Val::TRUE);
}

/// The same property stated directly against `End::Tail`, independent of
/// what the lowering happens to produce.
#[test]
fn tail_calls_run_in_constant_stack() {
use crate::eir::{Block, BlockId, Func, FuncId, Ty};
use crate::syntax::Span;

// `even(n) = n == 0 ? true : odd(n - 1)`, and vice versa.
let parity = |id: u32, other: u32, base: bool| Func {
id: FuncId(id),
name: Some(format!("parity{id}")),
params: vec![Ty::Int],
ret: Ty::Bool,
evidence: vec![],
captures: vec![],
blocks: vec![
Block {
id: BlockId(0),
params: vec![Reg(0)],
ops: vec![
Op::Lit(Reg(1), Lit::Int(0), Span::ZERO),
Op::Bin(Reg(2), BinOp::Eq, Reg(0), Reg(1), Span::ZERO),
],
end: End::Br(Reg(2), BlockId(1), BlockId(2)),
},
Block {
id: BlockId(1),
params: vec![],
ops: vec![Op::Lit(Reg(3), Lit::Bool(base), Span::ZERO)],
end: End::Ret(Reg(3)),
},
Block {
id: BlockId(2),
params: vec![],
ops: vec![
Op::Lit(Reg(4), Lit::Int(1), Span::ZERO),
Op::Bin(Reg(5), BinOp::Sub, Reg(0), Reg(4), Span::ZERO),
],
end: End::Tail(FuncId(other), vec![Reg(5)]),
},
],
span: Span::ZERO,
is_closure: false,
};

let module = crate::eir::Module {
funcs: vec![
parity(0, 1, true),
parity(1, 0, false),
Func {
id: FuncId(2),
name: Some("__main".to_string()),
params: vec![],
ret: Ty::Bool,
evidence: vec![],
captures: vec![],
blocks: vec![Block {
id: BlockId(0),
params: vec![],
ops: vec![
Op::Lit(Reg(0), Lit::Int(1_000_000), Span::ZERO),
Op::Call(Reg(1), FuncId(0), vec![Reg(0)], Span::ZERO),
],
end: End::Ret(Reg(1)),
}],
span: Span::ZERO,
is_closure: false,
},
],
strings: vec![],
ctors: vec![],
entry: FuncId(2),
};

let mut backend = NativeBackend;
let native = backend.compile(&module).expect("compilation failed");
assert_eq!(native.execute(), Val::TRUE);
}

/// A tail call to a closure has no lowering yet. It must fail loudly —
/// it used to return Unit without performing the call at all.
#[test]
fn tail_invoke_is_an_error_not_a_silent_unit() {
use crate::eir::{Block, BlockId, Func, FuncId, Ty};
use crate::syntax::Span;

let module = crate::eir::Module {
funcs: vec![Func {
id: FuncId(0),
name: Some("__main".to_string()),
params: vec![],
ret: Ty::Any,
evidence: vec![],
captures: vec![],
blocks: vec![Block {
id: BlockId(0),
params: vec![],
ops: vec![Op::Lit(Reg(0), Lit::Int(1), Span::ZERO)],
end: End::TailInvoke(Reg(0), vec![Reg(0)]),
}],
span: Span::ZERO,
is_closure: false,
}],
strings: vec![],
ctors: vec![],
entry: FuncId(0),
};

match NativeBackend.compile(&module) {
Ok(_) => panic!("tail invoke should not compile silently"),
Err(e) => assert!(e.message.contains("closure"), "unexpected error: {e}"),
}
}

#[test]
fn compile_division() {
let result = eval_native("[/ 10 3]").unwrap();
Expand Down
Loading
Loading