Skip to content

Commit cfb96f2

Browse files
wthollidayclaude
andcommitted
Add --aot AOT backend producing arm64-apple-ios .o + C header
For Audulus iOS where the stack interpreter isn't fast enough. `lyte file.lyte --no-recursion --entry init,process --aot out.o` emits a Mach-O arm64 object plus a companion header so host C/C++ code can link the compiled program directly. Exports are prefixed (default: filename stem; --aot-prefix overrides) and internal Lyte functions are demoted to Internal linkage so multiple AOT objects coexist in one binary. Math builtins map to libm; IO builtins become weak host-overridable hooks (noinline+optnone so the defaults aren't inlined away). Cancel-check and call-depth-check are elided — AOT requires --no-recursion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 450fbae commit cfb96f2

6 files changed

Lines changed: 1347 additions & 61 deletions

File tree

cli/src/main.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ struct Args {
5555
/// Entry point function name(s), comma-separated. Defaults to "main".
5656
#[clap(long)]
5757
entry: Option<String>,
58+
59+
/// Ahead-of-time compile to an arm64-apple-ios Mach-O object file at the
60+
/// given path. A companion `.h` is written next to it. Requires the
61+
/// `llvm` feature and implies `--no-recursion`.
62+
#[clap(long)]
63+
aot: Option<String>,
64+
65+
/// Symbol prefix for AOT outputs. Defaults to the stem of the --aot path.
66+
#[clap(long)]
67+
aot_prefix: Option<String>,
5868
}
5969

6070
fn run(args: Args) -> i32 {
@@ -168,6 +178,21 @@ fn run(args: Args) -> i32 {
168178

169179
compiler.print_ir = args.ir;
170180

181+
// Handle AOT compile (writes a .o + .h and exits).
182+
#[cfg(feature = "llvm")]
183+
{
184+
if let Some(out) = args.aot.as_deref() {
185+
return run_aot(&mut compiler, out, args.aot_prefix.as_deref());
186+
}
187+
}
188+
#[cfg(not(feature = "llvm"))]
189+
{
190+
if args.aot.is_some() {
191+
eprintln!("--aot requires the `llvm` cargo feature");
192+
return 1;
193+
}
194+
}
195+
171196
// Select backend via --backend flag, falling back to LYTE_BACKEND env var.
172197
let backend = if args.backend.is_empty() {
173198
std::env::var("LYTE_BACKEND").unwrap_or_default()
@@ -392,6 +417,67 @@ fn run(args: Args) -> i32 {
392417
0
393418
}
394419

420+
#[cfg(feature = "llvm")]
421+
fn run_aot(
422+
compiler: &mut lyte::Compiler,
423+
output: &str,
424+
prefix_override: Option<&str>,
425+
) -> i32 {
426+
if !compiler.no_recursion {
427+
eprintln!("--aot requires --no-recursion");
428+
return 1;
429+
}
430+
if !compiler.has_decls() {
431+
eprintln!("--aot: no declarations to compile");
432+
return 1;
433+
}
434+
if let Err(e) = compiler.specialize() {
435+
eprintln!("{}", e);
436+
return 1;
437+
}
438+
let path = std::path::PathBuf::from(output);
439+
let prefix = match prefix_override {
440+
Some(p) => p.to_string(),
441+
None => path
442+
.file_stem()
443+
.and_then(|s| s.to_str())
444+
.map(sanitize_aot_prefix)
445+
.unwrap_or_else(|| "lyte".to_string()),
446+
};
447+
if prefix.is_empty() {
448+
eprintln!("--aot: prefix is empty (use --aot-prefix to override)");
449+
return 1;
450+
}
451+
match compiler.compile_aot(&path, &prefix) {
452+
Ok(()) => 0,
453+
Err(e) => {
454+
eprintln!("{}", e);
455+
1
456+
}
457+
}
458+
}
459+
460+
#[cfg(feature = "llvm")]
461+
fn sanitize_aot_prefix(stem: &str) -> String {
462+
let mut out: String = stem
463+
.chars()
464+
.map(|c| {
465+
if c.is_ascii_alphanumeric() || c == '_' {
466+
c
467+
} else {
468+
'_'
469+
}
470+
})
471+
.collect();
472+
// C identifiers can't start with a digit.
473+
if let Some(first) = out.chars().next() {
474+
if first.is_ascii_digit() {
475+
out.insert(0, '_');
476+
}
477+
}
478+
out
479+
}
480+
395481
fn main() {
396482
let args = Args::parse();
397483

cli/tests/cli.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,128 @@ fn golden_tests_asm() -> goldentests::TestResult<()> {
4646
fn golden_tests_stack() -> goldentests::TestResult<()> {
4747
run_golden_tests("stack")
4848
}
49+
50+
#[test]
51+
#[cfg(feature = "llvm")]
52+
fn aot_emits_arm64_ios_object_and_header() {
53+
use std::io::Write;
54+
55+
let tmp = std::env::temp_dir().join("lyte_aot_smoke");
56+
let _ = std::fs::remove_dir_all(&tmp);
57+
std::fs::create_dir_all(&tmp).unwrap();
58+
59+
let src = tmp.join("smoke.lyte");
60+
let mut f = std::fs::File::create(&src).unwrap();
61+
writeln!(
62+
f,
63+
"var freq: f32\n\
64+
var sample_rate: f32\n\
65+
\n\
66+
init(rate: f32, f: f32) {{\n\
67+
sample_rate = rate\n\
68+
freq = f\n\
69+
}}\n\
70+
\n\
71+
phase() -> f32 {{\n\
72+
return freq / sample_rate\n\
73+
}}"
74+
)
75+
.unwrap();
76+
drop(f);
77+
78+
let out_o = tmp.join("smoke.o");
79+
let status = std::process::Command::new(LYTE_BIN)
80+
.arg(&src)
81+
.arg("--no-recursion")
82+
.arg("--entry")
83+
.arg("init,phase")
84+
.arg("--aot")
85+
.arg(&out_o)
86+
.status()
87+
.expect("failed to invoke lyte");
88+
assert!(status.success(), "--aot exited with {}", status);
89+
assert!(out_o.exists(), "object file not written");
90+
91+
let out_h = tmp.join("smoke.h");
92+
assert!(out_h.exists(), "header file not written");
93+
94+
// Check Mach-O 64-bit arm64 magic + cputype rather than shelling out to
95+
// `file`, so the test runs anywhere.
96+
let bytes = std::fs::read(&out_o).expect("read .o");
97+
assert!(bytes.len() >= 8, ".o too short");
98+
let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
99+
assert_eq!(magic, 0xfeed_facf, "expected Mach-O 64-bit magic");
100+
let cputype = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
101+
assert_eq!(cputype, 0x0100_000c, "expected arm64 cputype (got {:#x})", cputype);
102+
103+
let header = std::fs::read_to_string(&out_h).expect("read .h");
104+
assert!(header.contains("#define SMOKE_H"), "missing include guard");
105+
assert!(header.contains("SMOKE_STATE_SIZE"), "missing state-size macro");
106+
assert!(header.contains("smoke_init"), "missing init wrapper decl");
107+
assert!(header.contains("smoke_phase"), "missing phase wrapper decl");
108+
assert!(header.contains("SMOKE_OFFSET_FREQ"), "missing freq global offset");
109+
assert!(header.contains("smoke_globals"), "missing metadata table");
110+
assert!(header.contains("smoke_assert"), "missing assert hook decl");
111+
}
112+
113+
#[test]
114+
#[cfg(feature = "llvm")]
115+
fn aot_two_objects_have_disjoint_public_symbols() {
116+
// Two .lyte programs that both define internal helpers and entry points
117+
// with the same names must produce link-disjoint .o files when given
118+
// different prefixes.
119+
use std::io::Write;
120+
121+
let tmp = std::env::temp_dir().join("lyte_aot_collision");
122+
let _ = std::fs::remove_dir_all(&tmp);
123+
std::fs::create_dir_all(&tmp).unwrap();
124+
125+
for prefix in &["nodea", "nodeb"] {
126+
let src = tmp.join(format!("{}.lyte", prefix));
127+
let mut f = std::fs::File::create(&src).unwrap();
128+
writeln!(
129+
f,
130+
"helper(x: f32) -> f32 {{\n\
131+
return x * x\n\
132+
}}\n\
133+
\n\
134+
run(input: f32) -> f32 {{\n\
135+
return helper(input)\n\
136+
}}"
137+
)
138+
.unwrap();
139+
drop(f);
140+
let out_o = tmp.join(format!("{}.o", prefix));
141+
let status = std::process::Command::new(LYTE_BIN)
142+
.arg(&src)
143+
.arg("--no-recursion")
144+
.arg("--entry")
145+
.arg("run")
146+
.arg("--aot")
147+
.arg(&out_o)
148+
.status()
149+
.expect("failed to invoke lyte");
150+
assert!(status.success(), "--aot exited with {}", status);
151+
}
152+
153+
// Read both .o files and grep their byte content for the public symbols.
154+
// We don't parse Mach-O; we just confirm that the *other* program's
155+
// wrapper name does NOT appear in this .o.
156+
let a = std::fs::read(tmp.join("nodea.o")).unwrap();
157+
let b = std::fs::read(tmp.join("nodeb.o")).unwrap();
158+
assert!(contains_substr(&a, b"nodea_run"), "nodea.o missing its wrapper");
159+
assert!(contains_substr(&b, b"nodeb_run"), "nodeb.o missing its wrapper");
160+
assert!(!contains_substr(&a, b"nodeb_run"), "nodea.o leaks nodeb_run");
161+
assert!(!contains_substr(&b, b"nodea_run"), "nodeb.o leaks nodea_run");
162+
// Internal helper from either program should NOT appear as an external
163+
// symbol with the bare name; either it's inlined away or set to private
164+
// linkage. We can't tell without a Mach-O parser, but the public-name
165+
// disjointness check above is the load-bearing one for "can they link".
166+
}
167+
168+
#[cfg(feature = "llvm")]
169+
fn contains_substr(haystack: &[u8], needle: &[u8]) -> bool {
170+
haystack
171+
.windows(needle.len())
172+
.any(|w| w == needle)
173+
}

src/compiler.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,37 @@ impl Compiler {
768768
}
769769
}
770770

771+
/// Ahead-of-time compile to an `arm64-apple-ios` Mach-O object file and
772+
/// write a companion C header. Output paths are derived from
773+
/// `output_path` (e.g. `path/biquad.o` → `path/biquad.h`).
774+
///
775+
/// `prefix` is applied to every externally-visible symbol so multiple
776+
/// AOT objects can be linked into the same binary without collisions.
777+
#[cfg(feature = "llvm")]
778+
pub fn compile_aot(
779+
&self,
780+
output_path: &std::path::Path,
781+
prefix: &str,
782+
) -> Result<(), String> {
783+
if self.decls.decls.is_empty() {
784+
return Err("No declarations to compile".into());
785+
}
786+
if !self.no_recursion {
787+
return Err(
788+
"--aot requires --no-recursion (call-depth machinery is unavailable at link time)"
789+
.into(),
790+
);
791+
}
792+
let entry_points = self.effective_entry_points();
793+
crate::llvm_aot::compile_aot(
794+
&self.decls,
795+
&entry_points,
796+
output_path,
797+
prefix,
798+
self.print_ir,
799+
)
800+
}
801+
771802
/// Compile with LLVM and print IR, but do not execute.
772803
#[cfg(feature = "llvm")]
773804
pub fn print_llvm_ir(&mut self) {

src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ mod llvm_jit;
4949
#[cfg(feature = "llvm")]
5050
pub use llvm_jit::*;
5151

52+
#[cfg(feature = "llvm")]
53+
pub mod llvm_aot;
54+
5255
pub mod mangle;
5356

5457
mod hoist;

0 commit comments

Comments
 (0)