Skip to content

Commit 5eafe20

Browse files
Session D.5: extract CLI to omnimcode-cli, wire OMC_HBIT_JIT=1
Move src/main.rs out of omnimcode-core (where it couldn't depend on omnimcode-codegen without a workspace cycle) into a new omnimcode-cli package that depends on both. The shipped binary name is preserved (omnimcode-standalone) so scripts that invoke it keep working. What `OMC_HBIT_JIT=1` does now (with `--features llvm-jit`): - Compile the parsed program to bytecode - JIT-lower every user fn in dual-band mode; silently skip ones the codegen can't yet handle (strings/dicts/cross-fn calls) - Register a dispatch closure on the Interpreter that routes matching user-fn calls through the native fn pointer - Run the program tree-walk-as-usual; the dispatch hook short-circuits to native for the JIT'd fns Smoke test verifies: `OMC_HBIT_JIT=1 omnimcode-standalone f.omc` correctly JITs `double`, `factorial` (recursive), `sum_to` (while-loop with locals), and runs them at native speed (timing in Session E). Three release-build fixes were needed: 1. Workspace [profile.release] had `lto = "fat"` + `strip = true`. That combination causes LLVM JIT to segfault during JitContext::new because MCJIT does dlopen-style symbol lookup against the binary, and aggressive LTO + stripping scrubs those symbols. Changed to `lto = "off"`, `strip = "debuginfo"`, `codegen-units = 16`. Mild release-perf hit, JIT works. 2. jit_module was interleaving lower-then-extract per fn. That triggers JIT finalization on a partially-populated module and FunctionNotFound on the next recursive reference. Switched to two-phase: lower ALL eligible fns first, then extract fn pointers from the now-complete module. 3. extract_raw_fn_ptr failures inside jit_module no longer propagate via `?` (which aborted the whole JIT registry). Per-fn errors now skip that fn and continue; tree-walk handles the skipped fn. Help text updated: `omc --help` now documents `OMC_HBIT_JIT=1` and `OMC_HBIT_JIT_VERBOSE=1`. Workspace state: 149 omnimcode-core unit tests pass, 19 codegen tests pass, 18 OMC harmonic-lib tests pass, smoke test green in both tree-walk and JIT modes with identical output. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 41992c2 commit 5eafe20

6 files changed

Lines changed: 175 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ members = [
55
"omnimcode-wasm",
66
"omnimcode-lsp",
77
"omnimcode-codegen",
8+
"omnimcode-cli",
89
]
910
# omnimcode-python kept around but excluded from the default workspace.
1011
# It was the "Python embeds OMC" wrapper (extension-module mode); now
@@ -21,12 +22,25 @@ edition = "2021"
2122
authors = ["The Architect <architect@sovereign-lattice.io>"]
2223
license = "MIT"
2324

24-
# Shared profile settings for all workspace members
25+
# Shared profile settings for all workspace members.
26+
#
27+
# LTO is OFF because the LLVM JIT runtime (omnimcode-codegen +
28+
# inkwell) does dlopen-style symbol resolution against the binary's
29+
# symbol table at runtime; `lto = "fat"` inlines/mangles those
30+
# symbols and causes a segfault during JitContext::new. The cost
31+
# of disabling LTO is mild (a few % slower release binary) and is
32+
# acceptable in exchange for the JIT working in shipped binaries.
33+
# Per-package `lto` overrides aren't supported by cargo, so this
34+
# is a workspace-wide setting.
35+
#
36+
# `strip = "debuginfo"` keeps the symbol table (which LLVM JIT
37+
# needs) but discards debug info (which it doesn't). Binary size
38+
# stays in the same ballpark as `strip = true`.
2539
[profile.release]
2640
opt-level = 3
27-
lto = "fat"
28-
codegen-units = 1
29-
strip = true
41+
lto = "off"
42+
codegen-units = 16
43+
strip = "debuginfo"
3044

3145
[profile.dev]
3246
opt-level = 1

omnimcode-cli/Cargo.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[package]
2+
name = "omnimcode-cli"
3+
version.workspace = true
4+
edition.workspace = true
5+
authors.workspace = true
6+
license.workspace = true
7+
description = "OMNIcode standalone CLI — links the tree-walk interpreter, bytecode VM, optional Python embedding, and (under llvm-jit) the LLVM-backed dual-band JIT into one binary."
8+
9+
# Preserve the historical binary name so scripts that invoke
10+
# `omnimcode-standalone` keep working after the Session D.5 split.
11+
[[bin]]
12+
name = "omnimcode-standalone"
13+
path = "src/main.rs"
14+
15+
[features]
16+
default = ["python-embed"]
17+
# CPython embedding for `py_*` builtins. Forwards to core.
18+
python-embed = ["omnimcode-core/python-embed"]
19+
# LLVM-backed dual-band JIT. When set at compile time, the CLI
20+
# consults `OMC_HBIT_JIT=1` at runtime; if also set, eligible user
21+
# fns are routed through omnimcode-codegen instead of tree-walk/VM.
22+
llvm-jit = ["dep:omnimcode-codegen", "dep:inkwell"]
23+
24+
[dependencies]
25+
omnimcode-core = { path = "../omnimcode-core", default-features = false }
26+
omnimcode-codegen = { path = "../omnimcode-codegen", optional = true, features = ["llvm-jit"] }
27+
# inkwell is needed at the CLI level only because we leak the LLVM
28+
# Context for process-lifetime; the dispatch closure lives on the
29+
# Interpreter and references the engine memory.
30+
inkwell = { version = "0.5", features = ["llvm18-0"], optional = true }
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,100 @@ fn maybe_register_python(interp: &mut Interpreter) {
9797
#[cfg(not(feature = "python-embed"))]
9898
fn maybe_register_python(_interp: &mut Interpreter) {}
9999

100+
/// Wire the LLVM-backed dual-band JIT into the Interpreter when
101+
/// `OMC_HBIT_JIT=1` is set in the environment. Compiles the program
102+
/// to bytecode, attempts to JIT every user fn in dual-band mode, and
103+
/// installs a dispatch hook that routes future calls to the native
104+
/// code path.
105+
///
106+
/// The JIT context (LLVM Context + ExecutionEngine + native code
107+
/// pages) is `Box::leak`-ed because the compiled fn pointers must
108+
/// outlive the dispatch closure stored on the Interpreter, which in
109+
/// turn must live for the whole program. CLI tool process-lifetime
110+
/// is the right scope for this leak.
111+
///
112+
/// Functions whose bodies use ops the codegen layer doesn't yet
113+
/// support (strings, dicts, builtins, cross-fn calls) are silently
114+
/// skipped — they keep running through the tree-walk interpreter.
115+
#[cfg(feature = "llvm-jit")]
116+
fn maybe_register_jit(
117+
interp: &mut Interpreter,
118+
statements: &[omnimcode_core::ast::Statement],
119+
) {
120+
if std::env::var("OMC_HBIT_JIT").as_deref() != Ok("1") {
121+
return;
122+
}
123+
let module = match omnimcode_core::compiler::compile_program(statements) {
124+
Ok(m) => m,
125+
Err(e) => {
126+
eprintln!("[OMC_HBIT_JIT] compile_program failed: {} — falling back to tree-walk", e);
127+
return;
128+
}
129+
};
130+
// Leak the LLVM Context to give it a 'static lifetime. Required
131+
// because JitContext borrows from Context, and the dispatch
132+
// closure holds raw fn pointers into the JitContext's
133+
// ExecutionEngine. CLI process-lifetime is the right scope.
134+
let context: &'static inkwell::context::Context =
135+
Box::leak(Box::new(inkwell::context::Context::create()));
136+
let jit = match omnimcode_codegen::JitContext::new(context) {
137+
Ok(j) => j,
138+
Err(e) => {
139+
eprintln!("[OMC_HBIT_JIT] JitContext::new failed: {} — falling back", e);
140+
return;
141+
}
142+
};
143+
let jitted = match jit.jit_module(&module) {
144+
Ok(map) => map,
145+
Err(e) => {
146+
eprintln!("[OMC_HBIT_JIT] jit_module failed: {} — falling back", e);
147+
return;
148+
}
149+
};
150+
let n_jitted = jitted.len();
151+
let n_total = module.functions.len();
152+
if std::env::var("OMC_HBIT_JIT_VERBOSE").as_deref() == Ok("1") {
153+
eprintln!(
154+
"[OMC_HBIT_JIT] JIT'd {}/{} user fns to dual-band native code",
155+
n_jitted, n_total
156+
);
157+
for name in jitted.keys() {
158+
eprintln!(" - {}", name);
159+
}
160+
}
161+
// Leak the JitContext too — the dispatch closure references its
162+
// engine memory.
163+
let jit_static: &'static omnimcode_codegen::JitContext<'static> = Box::leak(Box::new(jit));
164+
let _ = jit_static; // currently unused; kept for documentation
165+
let dispatch: omnimcode_core::interpreter::JitDispatch = std::rc::Rc::new(
166+
move |name: &str, args: &[omnimcode_core::value::Value]| {
167+
use omnimcode_core::value::{HInt, Value};
168+
let jf = jitted.get(name)?;
169+
if args.len() != jf.arity {
170+
return None;
171+
}
172+
let mut int_args: Vec<i64> = Vec::with_capacity(args.len());
173+
for a in args {
174+
match a {
175+
Value::HInt(h) => int_args.push(h.value),
176+
Value::Bool(b) => int_args.push(if *b { 1 } else { 0 }),
177+
_ => return None, // non-int arg → fall through to tree-walk
178+
}
179+
}
180+
jf.call(&int_args)
181+
.map(|r| Ok(Value::HInt(HInt::new(r))))
182+
},
183+
);
184+
interp.set_jit_dispatch(Some(dispatch));
185+
}
186+
187+
#[cfg(not(feature = "llvm-jit"))]
188+
fn maybe_register_jit(
189+
_interp: &mut Interpreter,
190+
_statements: &[omnimcode_core::ast::Statement],
191+
) {
192+
}
193+
100194
/// `--install [SPEC]`. SPEC can be:
101195
///
102196
/// * a URL → fetch and store under that basename
@@ -504,6 +598,9 @@ fn print_help() {
504598
println!(" OMC_OPT_STATS=1 print optimizer pass statistics");
505599
println!(" OMC_STDLIB_PATH=... colon-separated module search path");
506600
println!(" OMC_NO_PYTHON=1 skip embedded CPython initialisation");
601+
println!(" OMC_HBIT_JIT=1 JIT eligible user fns through omnimcode-codegen");
602+
println!(" (dual-band <2 x i64> SSE2; LLVM-backed)");
603+
println!(" OMC_HBIT_JIT_VERBOSE=1 print which fns got JIT'd at startup");
507604
}
508605

509606
fn read_and_run(path: &str) -> Result<(), String> {
@@ -649,6 +746,7 @@ fn execute_program(source: &str) -> Result<(), String> {
649746

650747
let mut interpreter = Interpreter::new();
651748
maybe_register_python(&mut interpreter);
749+
maybe_register_jit(&mut interpreter, &statements);
652750
// OMC_HEAL_RETRY=1 — catch runtime errors after execution starts,
653751
// run the heal pass on a fresh copy of the AST, and retry. Captures
654752
// bugs that the static heal pass missed (e.g. dynamic /0, missing

omnimcode-codegen/src/lib.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -194,21 +194,18 @@ impl<'ctx> JitContext<'ctx> {
194194
&self,
195195
module: &omnimcode_core::bytecode::Module,
196196
) -> Result<HashMap<String, JittedFn>, CodegenError> {
197-
let mut out: HashMap<String, JittedFn> = HashMap::new();
197+
// Two-phase: lower ALL eligible fns into the LLVM module
198+
// first, THEN extract fn pointers. Interleaving the phases
199+
// makes get_function trigger JIT finalization on a partially-
200+
// populated module, which can refuse to resolve recursive or
201+
// cross-fn references that point to fns we haven't lowered
202+
// yet (FunctionNotFound).
203+
let mut to_extract: Vec<(String, String, usize)> = Vec::new(); // (orig, suffixed, arity)
198204
for (name, cf) in &module.functions {
199205
let suffixed = format!("{}_hbit", name);
200206
match self.lower_function_dual_band(cf) {
201207
Ok(_) => {
202-
// get_function::<F> triggers JIT finalization and
203-
// returns a JitFunction wrapping the raw pointer.
204-
// We dispatch on arity to pick the right F so we
205-
// can extract the raw fn pointer for storage.
206-
let arity = cf.params.len();
207-
let fn_ptr = unsafe { self.extract_raw_fn_ptr(&suffixed, arity)? };
208-
out.insert(
209-
name.clone(),
210-
JittedFn { arity, fn_ptr },
211-
);
208+
to_extract.push((name.clone(), suffixed, cf.params.len()));
212209
}
213210
Err(_) => {
214211
// Lowering failed mid-emission. The LLVM module
@@ -222,6 +219,18 @@ impl<'ctx> JitContext<'ctx> {
222219
}
223220
}
224221
}
222+
let mut out: HashMap<String, JittedFn> = HashMap::new();
223+
for (name, suffixed, arity) in to_extract {
224+
match unsafe { self.extract_raw_fn_ptr(&suffixed, arity) } {
225+
Ok(fn_ptr) => {
226+
out.insert(name, JittedFn { arity, fn_ptr });
227+
}
228+
Err(_) => {
229+
// Extraction failure → skip this fn, keep going.
230+
// Tree-walk will handle it.
231+
}
232+
}
233+
}
225234
Ok(out)
226235
}
227236

omnimcode-core/Cargo.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ documentation = "https://docs.rs/omnimcode-core"
99
repository = "https://github.com/sovereignlattice/omnimcode"
1010
readme = "README.md"
1111

12-
[[bin]]
13-
name = "omnimcode-standalone"
14-
path = "src/main.rs"
15-
1612
[lib]
1713
path = "src/lib.rs"
1814

0 commit comments

Comments
 (0)