Skip to content
Open
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
50 changes: 50 additions & 0 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,56 @@ interpreted-dispatch model, not a single hot spot — each remaining local
lever measures ≤ ~8%. The tc-cache row is verdict memoization (off by
default), not raw speed.

## Cross-port: the wider field (rust vs cl vs lua)

`scripts/cross-port-bench-4way.sh` extends the headline harness to the two
Shen-Lua execution modes (LuaJIT and PUC Lua 5.4). Same upstream suite, same
machine, interleaved min-of-N, all four timed identically by external
`/usr/bin/time` wall-clock.

**These numbers have NOT been taken on a quiet machine — treat them as
indicative, not authoritative, and re-run with `cross-port-bench-4way.sh` on an
idle box before quoting.** What is firm (stable across load): **shen-cl is
fastest** (~1.0 s, a saved SBCL image) and **PUC Lua is slowest** (~12–14 s, a
pure interpreter). What is **not** yet resolved: the **shen-rust vs LuaJIT**
ranking — they land close together in the ~2.5 s range and the order flips with
machine load (rust's release tree-walk vs LuaJIT's trace JIT). An earlier draft
of this section claimed "rust ~2× faster than LuaJIT"; that was an
apples-to-oranges error — it compared rust's *internal eval timer* against a
*contended* LuaJIT wall-time. Under the consistent harness they are roughly
comparable. Do not repeat the 2× claim without a clean measurement.

| Port | engine | indicative wall (loaded box) | status |
|---|---|---:|---|
| shen-cl | SBCL (saved image) | ≈ 1.0 s | firm: fastest |
| **shen-rust** | release tree-walk | ≈ 2.5 s | ~tied with LuaJIT |
| shen-lua | LuaJIT | ≈ 2.5 s | ~tied with rust |
| shen-lua | PUC Lua 5.4 | ≈ 12–14 s | firm: slowest |

Fairness note: shen-rust (AOT kernel in the binary) and shen-cl (saved Lisp
image) boot in ~0; shen-lua loads its kernel from a cache first
(`load_kernel` ≈ 0.04 s LuaJIT / ≈ 0.39 s PUC, per `run-kernel-tests.lua`'s
printed split) — small relative to the multi-second *execution*, so total
wall-clock is a fair execution proxy. (The Lua driver prints
`Counters: passed=0 failed=0` at the end — a counter-name readout bug in the
driver, not skipped work; the suite genuinely runs and self-reports
`pass rate ... 100%` per test.)

The interesting LuaJIT finding is independent of the wall-clock ranking: a big
chunk of the suite never JIT-compiles at all, which is why LuaJIT only ties
shen-rust's non-JIT tree-walker and lands in the low end of its usual range over
PUC Lua (~5×) rather than pulling decisively ahead. The cause: a Shen port
allocates a fresh closure per `lambda` / `freeze` / partial application, which
shen-lua emits as Lua `MKFUN(n, function(...) end)` (`compiler.lua:823/845`).
That compiles to the `FNEW` bytecode, which LuaJIT's tracer **cannot compile**
(NYI). On the suite, `luajit -jv` records **977 trace aborts on `FNEW`** and
**579 on `UCLO`** (upvalue-close), and **456 traces blacklisted** — so the
hottest closure-allocating paths permanently fall back to the interpreter. This
is *not* a warm-up artifact (blacklisted traces never recompile no matter how
long the process lives); the lever is in the port's codegen (extend shen-lua's
existing freeze free-var hoisting — the `BIND(kbody[N], …)` scheme — to
lambdas / hot loops to cut `FNEW`). See the LuaJIT-warm note for shen-lua.

## Warm / served: VM vs tree-walker

`scripts/warm-bench.sh` (+ `benches/warm_typecheck.rs`) measures a load-once /
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"crates/shengen-rust",
"crates/klcompile",
"crates/ratatoskr-build",
"crates/shenffi",
"bin/shen-rust",
"examples/shen-cedar-authz",
]
Expand Down
17 changes: 17 additions & 0 deletions PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ The living, detailed record is in `design/`:
filtered closure-capture caching measured −3.5% — the whole-scope memcpy
in `capture_used` beats per-creation lookups even with the free-var walk
amortized.
5. **Hot-leaf inline / cold-outline round, 2026-06-20** (~6% less CPU work,
paired user-CPU min-of-13, B<A in ~9/11 runs — measured on a *loaded*
machine where wall-clock was unusable, so user-CPU time was the
contention-robust proxy): three per-step leaves that a fresh `--kernel-tests`
leaf profile flagged as un-inlined call frames, each because a *cold* error
path (`format!` / `ShenError::cancelled` string-building) bloated an
otherwise tiny hot body and blocked LLVM from folding it into its AOT call
sites. Fixed by splitting each into a tiny `#[inline]` hot path + a
`#[cold] #[inline(never)]` error constructor: **`is_truthy`** (the AOT `if`
predicate, 55→7 self-samples), **`charge_step`** (per-step budget check,
45→0 — fully inlined into `eval_in`), and routing **`make_aot_closure` /
`global_value` / `fn_value`** through the existing pointer-cached
`intern_static` (they took `&str` and re-probed the intern HashMap on every
AOT lambda / `value` / `fn` evaluation; the AOT call-target path already used
the cache). 134/0 across tree-walk / VM / GC / served, clippy/fmt clean. The
work didn't vanish — it CSE'd into `eval_in` and the call sites — but the
per-step call/return overhead and cold-blob bloat did.

## Why the remaining ~3.0× is structural

Expand Down
28 changes: 21 additions & 7 deletions crates/shen-rust/src/aot/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,28 @@ fn call_or_apply(interp: &mut Interp, f: Value, args: &[Value]) -> ShenResult<Va

/// Match Shen's boolean semantics: `Bool(true)`, `Sym(true)` → true;
/// `Bool(false)`, `Sym(false)` → false; anything else → error.
#[inline]
pub fn is_truthy(interp: &Interp, v: &Value) -> ShenResult<bool> {
// Hot path is a single tag decode; keep it tiny so this folds into the
// generated AOT `if` site. The non-boolean diagnostic is cold and outlined
// so its `format!` doesn't block inlining (it was the bulk of this leaf's
// self-time on the kernel-tests profile).
if let Some(b) = v.as_bool() {
return Ok(b);
}
match v.as_sym() {
Some(s) if s == interp.well_known.k_true => Ok(true),
Some(s) if s == interp.well_known.k_false => Ok(false),
_ => Err(ShenError::new(format!("aot: not a boolean: {v:?}"))),
_ => Err(not_a_boolean(v)),
}
}

#[cold]
#[inline(never)]
fn not_a_boolean(v: &Value) -> ShenError {
ShenError::new(format!("aot: not a boolean: {v:?}"))
}

/// Wrap a Rust closure as a Shen closure value. Used for AOT-compiled lambdas.
/// `arity` is the formal-parameter count; partial application works via the same
/// path as any other closure.
Expand All @@ -117,7 +128,7 @@ pub fn is_truthy(interp: &Interp, v: &Value) -> ShenResult<bool> {
/// and the move-captures hold copies of the same tagged words — the closure body
/// is unchanged. Empty when the lambda captures nothing.
pub fn make_aot_closure<F>(
name: &str,
name: &'static str,
arity: usize,
f: F,
captures: Vec<Value>,
Expand All @@ -126,7 +137,10 @@ pub fn make_aot_closure<F>(
where
F: Fn(&mut Interp, &[Value]) -> ShenResult<Value> + 'static,
{
let sym = interp.intern(name);
// `name` is always an AOT-emitted literal (`"<lambda>"`/`"<freeze>"`);
// route through the pointer-cached path so hot lambda allocation never
// re-probes the intern HashMap.
let sym = interp.intern_static(name);
let closure = Closure {
name: Some(sym),
arity,
Expand All @@ -137,8 +151,8 @@ where
}

/// Look up a global. Used for `(value GLOBAL)` form.
pub fn global_value(interp: &mut Interp, name: &str) -> ShenResult<Value> {
let sym = interp.intern(name);
pub fn global_value(interp: &mut Interp, name: &'static str) -> ShenResult<Value> {
let sym = interp.intern_static(name);
interp
.env
.get_global(sym)
Expand All @@ -147,8 +161,8 @@ pub fn global_value(interp: &mut Interp, name: &str) -> ShenResult<Value> {
}

/// Look up a function as a value (`(fn NAME)`).
pub fn fn_value(interp: &mut Interp, name: &str) -> ShenResult<Value> {
let sym = interp.intern(name);
pub fn fn_value(interp: &mut Interp, name: &'static str) -> ShenResult<Value> {
let sym = interp.intern_static(name);
interp
.env
.get_fn(sym)
Expand Down
16 changes: 15 additions & 1 deletion crates/shen-rust/src/interp/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,8 +530,22 @@ impl Interp {
/// Exhaustion is sticky — `remaining_steps` parks at `Some(0)` so a
/// cancellation that slips past a `trap-error` handler re-fires on the
/// handler's first step instead of letting evaluation resume.
#[inline]
#[inline(always)]
pub(crate) fn charge_step(&mut self) -> ShenResult<()> {
// Default config sets neither budget — fold to one combined branch the
// caller's hot loop can predict-not-taken. Both Options are `None`, so
// this is a single `is_some` test pair with no body. The cold
// exhaustion/deadline machinery is outlined so it never bloats this
// into a real (un-inlined) call frame.
if self.remaining_steps.is_none() && self.deadline.is_none() {
return Ok(());
}
self.charge_step_limited()
}

#[cold]
#[inline(never)]
fn charge_step_limited(&mut self) -> ShenResult<()> {
if let Some(n) = self.remaining_steps {
if n == 0 {
return Err(ShenError::cancelled("cancelled: step budget exhausted"));
Expand Down
18 changes: 11 additions & 7 deletions crates/shen-rust/src/symbol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ impl Hasher for FnvHasher {
type BuildFnv = BuildHasherDefault<FnvHasher>;

/// Slot count of the direct-mapped `&'static str`-pointer cache. Power of
/// two; 8192 × 16 B = 128 KB per `Interner`. The kernel has a few thousand
/// two; 8192 × 24 B = 192 KB per `Interner`. The kernel has a few thousand
/// distinct AOT callee names, so collisions (two literals mapping to one
/// slot) are rare and only cost a re-intern through `by_name`.
const PTR_CACHE_SLOTS: usize = 8192;
Expand All @@ -76,16 +76,19 @@ pub struct Interner {
/// loser re-interns through `by_name` — correct because `intern` is
/// idempotent. Per-`Interner` (never process-global), so it stays
/// correct even when several interpreters with different id assignments
/// coexist.
by_ptr: Box<[(usize, SymId)]>,
/// coexist. Keyed by `(addr, len)`, not addr alone: a `&'static str` that
/// is a *prefix slice* of another literal shares its start address, so the
/// length disambiguates them (today's callers pass whole literals, but the
/// helper is `pub`, so this keeps it sound for any static-str caller).
by_ptr: Box<[(usize, usize, SymId)]>,
}

impl Default for Interner {
fn default() -> Self {
Interner {
names: Vec::new(),
by_name: HashMap::default(),
by_ptr: vec![(0, SymId(0)); PTR_CACHE_SLOTS].into_boxed_slice(),
by_ptr: vec![(0, 0, SymId(0)); PTR_CACHE_SLOTS].into_boxed_slice(),
}
}
}
Expand Down Expand Up @@ -114,16 +117,17 @@ impl Interner {
#[inline]
pub fn intern_static(&mut self, name: &'static str) -> SymId {
let key = name.as_ptr() as usize;
let len = name.len();
// Literals are ≥1-byte aligned with addresses spread across rodata;
// fold a high-bit window in so neighbours in one object file don't
// contend for adjacent-slot runs.
let idx = ((key >> 3) ^ (key >> 16)) & (PTR_CACHE_SLOTS - 1);
let (k, id) = self.by_ptr[idx];
if k == key {
let (k, l, id) = self.by_ptr[idx];
if k == key && l == len {
return id;
}
let id = self.intern(name);
self.by_ptr[idx] = (key, id);
self.by_ptr[idx] = (key, len, id);
id
}

Expand Down
15 changes: 15 additions & 0 deletions crates/shenffi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "shenffi"
version.workspace = true
edition.workspace = true

# A thin C-ABI surface over shen-rust, so the interpreter can be embedded in
# non-Rust hosts (Swift / iOS, etc.) as a static library or framework.
# - staticlib -> libshenffi.a (link into an Xcode app / .xcframework)
# - cdylib -> libshenffi.dylib (handy for desktop linking / testing)
[lib]
name = "shenffi"
crate-type = ["staticlib", "cdylib", "rlib"]

[dependencies]
shen-rust = { path = "../shen-rust" }
79 changes: 79 additions & 0 deletions crates/shenffi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# shenffi — Swift / C bindings for shen-rust

A thin C-ABI wrapper that embeds the [shen-rust](../shen-rust) interpreter in
non-Rust hosts — primarily **Swift on iOS/macOS**. shen-rust runs the Shen
kernel via AOT-compiled native code + a bytecode VM (no JIT in the default
build), so it's App Store-safe: it links into your app as a static library, no
runtime codegen.

## The C surface (`include/shenffi.h`)

```c
ShenCtx *shen_boot(const char *kernel_dir); // NULL -> auto-locate kernel
char *shen_eval(ShenCtx *ctx, const char *src); // -> rendered result / "error: …"
void shen_string_free(char *s);
void shen_free(ShenCtx *ctx);
```

`shen_eval` runs a **Shen-level** expression through the kernel's own pipeline
(`read-from-string` → `head` → `eval`) and renders the result with `shen.app`,
so output matches the REPL.

## Swift wrapper (`swift/ShenRust.swift`)

```swift
let shen = try ShenRust(kernelDir: bundleKernelPath) // nil to auto-locate
print(shen.eval("(map (/. X (* X X)) [1 2 3 4])")) // -> [1 4 9 16]
```

## Build

**macOS (desktop / quick test):**
```sh
cargo build --release -p shenffi # -> target/release/libshenffi.{a,dylib}
# round-trip demo:
cd crates/shenffi
swiftc -O -import-objc-header include/shenffi.h swift/ShenRust.swift swift/main.swift \
-L ../../target/release -lshenffi -o /tmp/shenrust_demo
DYLD_LIBRARY_PATH=../../target/release /tmp/shenrust_demo
```

**iOS (device + simulator XCFramework):**
```sh
crates/shenffi/build-xcframework.sh # -> target/ShenRust.xcframework
```
Then in Xcode: add `ShenRust.xcframework`, set `shenffi.h` as the bridging
header (or wrap in a module map), and drop in `swift/ShenRust.swift`.

## Kernel on iOS

`shen_boot(NULL)` auto-locates the kernel by walking the filesystem — fine on
desktop, not on a sandboxed device. Two device-friendly options:

1. **Bundle the `.kl` kernel** as an app resource and pass its directory path to
`shen_boot(path)`.
2. **Embed a shaken `kernel.kl`** (produced by [ratatoskr](../../ratatoskr)) and
boot from the in-memory source via shen-rust's `boot_from_kl_source` — no
filesystem dependency. (Exposing that through the C ABI is a small addition;
the current `shen_boot` takes a directory path.)

## Embedding a tree-shaken Shen program

`shen_boot_shaken(kernel_kl, prog_kl)` boots an arbitrary Ratatoskr-shaken slice
— a minimal `kernel.kl` (only the kernel functions the program reaches) plus an
optional program `.kl` — straight from in-memory source, no filesystem access.
This is how an app embeds a specific Shen program without the full kernel.

For a worked end-to-end example (the **shen-cas** computer-algebra system shaken
by [ratatoskr](../../ratatoskr), wrapped as a `CasEngine` + a `shen_cas_*` C ABI,
and driven from both an iced GUI and SwiftUI), see the **`cas-engine` crate in
the [shen-calc](../../../shen-calc) repo** — the app that consumes it. shenffi
itself stays program-agnostic.

## Status

Verified: the full shen-rust + shenffi stack **compiles for `aarch64-apple-ios`
and `aarch64-apple-ios-sim`**, packages into an `.xcframework`, and the
Swift→Rust→Shen round-trip runs correctly on macOS. The default shen-rust build
has **no JIT** (the optional Cranelift JIT is off by default), so nothing relies
on runtime code generation.
27 changes: 27 additions & 0 deletions crates/shenffi/build-xcframework.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Build ShenRust.xcframework (iOS device + simulator + macOS) from the shenffi
# crate. Output: <workspace>/target/ShenRust.xcframework — drag into an Xcode app,
# add the bridging header (shenffi.h) + ShenRust.swift, and you're done.
#
# The macOS slice (aarch64-apple-darwin) lets a native macOS target embed the
# same generic Shen interpreter as the iOS slices.
set -euo pipefail

cd "$(dirname "$0")/../.." # workspace root (shen-rust/)
HDRS="crates/shenffi/include"

for tgt in aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin; do
rustup target add "$tgt" >/dev/null 2>&1 || true
echo "building shenffi for $tgt ..."
cargo build --release -p shenffi --target "$tgt"
done

echo "packaging ShenRust.xcframework ..."
rm -rf target/ShenRust.xcframework
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libshenffi.a -headers "$HDRS" \
-library target/aarch64-apple-ios-sim/release/libshenffi.a -headers "$HDRS" \
-library target/aarch64-apple-darwin/release/libshenffi.a -headers "$HDRS" \
-output target/ShenRust.xcframework

echo "done -> target/ShenRust.xcframework"
Loading