From b65baa13c20af6982ffd7e083aece97bc1a4eeac Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 18 Aug 2026 10:48:35 -0400 Subject: [PATCH 1/2] Kernel perf: fix opt-level, stop allocating in the interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes and one negative result. **opt-level.** The crate was scaffolded `opt-level = "z"`, which suppresses the inlining a dispatch loop lives on. Moving to 3 (with codegen-units = 1) is worth 3.3x on the call-heavy benchmark and 1.5x on the loop benchmark, measured back to back on the same machine. This dominates everything else here; the image is small either way. **Allocation.** The interpreter allocated four times per call — two `Rc::new(Vec::new())` for empty capture lists, the operand vector, and the register file — plus one per jump for the block params. Now: one shared empty-captures Rc, a pool of retired register files, operands read into an inline buffer, and params borrowed out of the module instead of cloned. Tail calls recycle their own outgoing register file, which is what a tail-recursive loop needs since it never returns through a frame. That took the benchmark from 89,858 allocations to 47, and **bought no measurable time.** Interleaved A/B runs put the two builds inside each other's noise. The PR that landed the kernel claimed allocation was the bottleneck; that was wrong. The change is kept because interpreting in near-constant memory is worth having in a kernel with no OOM killer behind the heap — not as a speedup. A slab allocator, which is where this was heading, is not worth building: there is nothing left for it to allocate, and even at 90k allocations the first-fit list was not costing measurable time. Adds the harness this was measured with: boot/bench.oo (calls, sequences), boot/loop.oo (tail recursion, so no frame pushes), exact op counts from the VM's dispatch counter, exact allocation counts from the global allocator, and bench.sh to take a minimum over several boots because wall-clock inside an emulator on a shared machine is only ever biased upward. Remaining: ~500 ns/op against ~7 ns/op for a minimal native dispatch loop on the same emulator. loop.oo and bench.oo cost about the same per op, so the gap is per-op dispatch, not calling. Chasing it needs an idle machine and a profiler rather than more guessing. `make check` still passes: same output on the host and on bare metal. Co-Authored-By: Claude Opus 5 --- crates/loon-kernel/Cargo.toml | 6 +- crates/loon-kernel/README.md | 47 ++++++++- crates/loon-kernel/bench.sh | 20 ++++ crates/loon-kernel/boot/bench.oo | 15 +++ crates/loon-kernel/boot/loop.oo | 7 ++ crates/loon-kernel/build.rs | 27 +++-- crates/loon-kernel/src/eir/vm.rs | 167 +++++++++++++++++++++++++------ crates/loon-kernel/src/heap.rs | 10 ++ crates/loon-kernel/src/main.rs | 33 ++++++ docs/plans/2026-07-01-loon-os.md | 27 +++-- 10 files changed, 312 insertions(+), 47 deletions(-) create mode 100755 crates/loon-kernel/bench.sh create mode 100644 crates/loon-kernel/boot/bench.oo create mode 100644 crates/loon-kernel/boot/loop.oo diff --git a/crates/loon-kernel/Cargo.toml b/crates/loon-kernel/Cargo.toml index 4b981ba..1862da7 100644 --- a/crates/loon-kernel/Cargo.toml +++ b/crates/loon-kernel/Cargo.toml @@ -15,5 +15,9 @@ panic = "abort" [profile.release] panic = "abort" -opt-level = "z" +# An interpreter lives or dies on inlining, and `opt-level = "z"` suppresses +# it — the dispatch loop was ~150x slower than a native one under `z`. The +# image is small either way; there is nothing here worth trading speed for. +opt-level = 3 lto = true +codegen-units = 1 diff --git a/crates/loon-kernel/README.md b/crates/loon-kernel/README.md index aee5e79..4ebb653 100644 --- a/crates/loon-kernel/README.md +++ b/crates/loon-kernel/README.md @@ -46,6 +46,47 @@ outward, which only works if capturing moves every handler at or above the prompt into the continuation. `boot/init.oo` exercises forwarding, aborting (a clause that never resumes) and non-tail resume for exactly this reason. +## Benchmarking + +```bash +./bench.sh 7 # boot 7 times, report the best result per benchmark +``` + +`boot/bench.oo` (calls and sequences) and `boot/loop.oo` (tail self-recursion, +which lowers to a jump and so never pushes a frame) run before init and report +ops, ns/op and allocation count. Op counts come from the VM's own dispatch +counter and allocation counts from the global allocator, so both are exact and +identical run to run. **Wall-clock is not:** it is measured inside an emulator +on a shared machine and only ever biased upward, which is why `bench.sh` +reports a minimum over several runs. Treat a timing difference under ~30% as +noise unless you can reproduce it by interleaving two builds. + +## Performance notes + +Two things were measured properly, and one of them was a surprise. + +**`opt-level` dominates everything.** The crate was scaffolded with +`opt-level = "z"`, which suppresses the inlining a dispatch loop depends on. +Switching to `3` was worth **3.3x** on `bench` and 1.5x on `loop`, measured +back to back on the same machine. Nothing else came close. + +**Allocation was never the bottleneck.** Pooling register files and keeping +operands off the heap took the `bench` workload from 89,858 allocations to 47, +and bought *no measurable time* — interleaved A/B runs put the two builds +inside each other's noise. The changes are kept because a kernel that +interprets in near-constant memory is worth having on its own terms: no +allocator pressure, no fragmentation over long uptimes, no dependence on a +heap that has no OOM killer behind it. They are not a speedup, and an earlier +claim that they would be was wrong. + +**A slab allocator is therefore not worth building.** There is nothing left +for it to allocate, and even at 90k allocations the existing first-fit list +was not costing measurable time. + +What remains is roughly 500 ns/op against ~7 ns/op for a minimal native +dispatch loop under the same emulator. That gap is real and unexplained; +chasing it needs an idle machine and a profiler, not more guessing. + ## Known limits - **Cooperative only.** No timer interrupt yet, so a pure loop owns the @@ -54,6 +95,6 @@ prompt into the continuation. `boot/init.oo` exercises forwarding, aborting races with it. SMP needs a real one. - **Partial builtin set.** Intrinsics the runtime lacks raise a loud error naming the builtin; they never silently return `()`. -- **Slow.** Roughly 0.9 µs per interpreted op — the interpreter allocates a - register file per call and an operand vector per op, on a first-fit - allocator that costs ~0.5 µs per allocation. Nothing here is tuned yet. +- **Slow.** Roughly 500 ns per interpreted op under emulation, against ~7 ns + for a minimal native dispatch loop on the same emulator. See the + performance notes above for what that is and is not. diff --git a/crates/loon-kernel/bench.sh b/crates/loon-kernel/bench.sh new file mode 100755 index 0000000..8b4c017 --- /dev/null +++ b/crates/loon-kernel/bench.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Run the kernel N times and report the best result for each benchmark. +# Wall-clock under emulation is noisy and only ever biased upward by load, so +# the minimum is the least contaminated estimate. Allocation counts are +# deterministic and identical across runs. +N=${1:-7} +K=target/riscv64gc-unknown-none-elf/release/loon-kernel +for i in $(seq 1 "$N"); do + timeout 300 qemu-system-riscv64 -machine virt -cpu rv64 -smp 1 -m 128M \ + -nographic -serial mon:stdio -bios default -kernel "$K" < /dev/null 2>/dev/null +done | awk ' + /^(loop|bench)/ { + name = $1; sub(/:$/, "", name) + for (i = 1; i <= NF; i++) if ($i == "ns/op,") { ns = $(i-1)+0 } + for (i = 1; i <= NF; i++) if ($i == "allocs") { al = $(i-1)+0 } + if (!(name in best) || ns < best[name]) best[name] = ns + allocs[name] = al + } + END { for (n in best) printf "%-6s min %5d ns/op %7d allocs\n", n, best[n], allocs[n] } +' | sort diff --git a/crates/loon-kernel/boot/bench.oo b/crates/loon-kernel/boot/bench.oo new file mode 100644 index 0000000..b49707c --- /dev/null +++ b/crates/loon-kernel/boot/bench.oo @@ -0,0 +1,15 @@ +; Interpreter benchmark. Deliberately IO-free: console bytes are MMIO traps +; into the emulator and would swamp what we are trying to measure. +; +; Two shapes that stress different paths — deep recursion (call frames, the +; register file) and sequence work (operand vectors, closure application). + +[fn fib [n] + [if [< n 2] n [+ [fib [- n 1]] [fib [- n 2]]]]] + +[fn main [] + [let a [fib 20]] + [let xs [map [range 0 400] [fn [n] [* n 3]]]] + [let ys [filter xs [fn [n] [> n 200]]]] + [let b [fold ys 0 [fn [acc n] [+ acc n]]]] + [+ a b]] diff --git a/crates/loon-kernel/boot/loop.oo b/crates/loon-kernel/boot/loop.oo new file mode 100644 index 0000000..abe695d --- /dev/null +++ b/crates/loon-kernel/boot/loop.oo @@ -0,0 +1,7 @@ +; Pure dispatch cost: tail self-recursion lowers to `Recur` (a jump to block +; zero), so this never pushes a frame. Whatever this costs per op is the +; interpreter's floor; the gap to bench.oo is what calling costs. +[fn spin [n acc] + [if [= n 0] acc [spin [- n 1] [+ acc n]]]] + +[fn main [] [spin 20000 0]] diff --git a/crates/loon-kernel/build.rs b/crates/loon-kernel/build.rs index 5f6e022..19cb6cd 100644 --- a/crates/loon-kernel/build.rs +++ b/crates/loon-kernel/build.rs @@ -5,28 +5,38 @@ //! loon-lang directly) keeps the host toolchain entirely out of the //! bare-metal build graph. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; fn main() { let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - let src = manifest.join("boot/init.oo"); - let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("init.img"); let workspace = manifest.join("../../Cargo.toml"); - - println!("cargo:rerun-if-changed={}", src.display()); println!("cargo:rerun-if-changed=build.rs"); + for (name, var) in [ + ("init", "LOON_BOOT_IMAGE"), + ("bench", "LOON_BENCH_IMAGE"), + ("loop", "LOON_LOOP_IMAGE"), + ] { + let src = manifest.join(format!("boot/{name}.oo")); + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join(format!("{name}.img")); + println!("cargo:rerun-if-changed={}", src.display()); + image(&workspace, &manifest, &src, &out); + println!("cargo:rustc-env={var}={}", out.display()); + } +} + +fn image(workspace: &Path, manifest: &Path, src: &Path, out: &Path) { let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) // Run from the workspace root: this crate's .cargo/config.toml pins // a bare-metal target, and the nested build must not inherit it. .current_dir(manifest.join("../..")) .args(["run", "-q", "--manifest-path"]) - .arg(&workspace) + .arg(workspace) .args(["-p", "loon-cli", "--", "image"]) - .arg(&src) + .arg(src) .arg("-o") - .arg(&out) + .arg(out) // Cargo's env leaks the bare-metal target into the nested build and // makes it try to compile the compiler for riscv; clear it. .env_remove("CARGO_ENCODED_RUSTFLAGS") @@ -38,5 +48,4 @@ fn main() { if !status.success() { panic!("building the boot image from {} failed", src.display()); } - println!("cargo:rustc-env=LOON_BOOT_IMAGE={}", out.display()); } diff --git a/crates/loon-kernel/src/eir/vm.rs b/crates/loon-kernel/src/eir/vm.rs index 185ac3a..e8c9be2 100644 --- a/crates/loon-kernel/src/eir/vm.rs +++ b/crates/loon-kernel/src/eir/vm.rs @@ -25,6 +25,32 @@ pub trait Host { fn ticks(&mut self) -> i64; } +/// Operands read out of registers for one instruction. +/// +/// They die before the next op is dispatched, so heap-allocating a vector per +/// op bought nothing; this keeps the common case off the heap entirely and +/// spills for the rare wide call. Note this did *not* show up as a speedup +/// (see README) — the value is a near-constant-memory interpreter, not +/// throughput. The inline capacity is kept small deliberately: it is copied +/// on every read, so widening it trades one cost for another. +pub struct Operands { + inline: [Val; INLINE_OPERANDS], + len: usize, + spill: Vec, +} + +const INLINE_OPERANDS: usize = 4; + +impl Operands { + fn as_slice(&self) -> &[Val] { + if self.spill.is_empty() { + &self.inline[..self.len] + } else { + &self.spill + } + } +} + pub struct Frame { func: FuncId, block: BlockId, @@ -77,8 +103,23 @@ pub struct Vm<'m, H: Host> { captures: Rc>, /// Bounds runaway programs; there is no watchdog timer to save us yet. fuel: u64, + /// Dispatch-loop iterations, for benchmarking. One per instruction or + /// terminator, so it is a real op count rather than a wall-clock proxy. + steps: u64, + /// One shared empty capture list. A plain function has no captures, so + /// `Rc::new(Vec::new())` at every call site would allocate an RcBox per + /// call to hold nothing; cloning this is a refcount bump instead. + no_caps: Rc>, + /// Retired register files, kept for the next call. Frames are strictly + /// stack-shaped, so a returning call almost always hands back a buffer + /// the next one can take. + reg_pool: Vec>, } +/// How many register files to keep parked. Deep recursion churns through +/// these; past a few hundred the memory is better left to the heap. +const REG_POOL_MAX: usize = 256; + pub type VmResult = Result; impl<'m, H: Host> Vm<'m, H> { @@ -95,9 +136,17 @@ impl<'m, H: Host> Vm<'m, H> { m, host, fuel: u64::MAX, + steps: 0, + no_caps: Rc::new(Vec::new()), + reg_pool: Vec::new(), } } + /// Instructions and terminators executed so far. + pub fn steps(&self) -> u64 { + self.steps + } + pub fn with_fuel(mut self, fuel: u64) -> Self { self.fuel = fuel; self @@ -117,7 +166,31 @@ impl<'m, H: Host> Vm<'m, H> { self.regs[i] = v; } - fn read(&self, rs: &[Reg]) -> Vec { + /// Read operands without touching the heap unless there are many. + fn read(&self, rs: &[Reg]) -> Operands { + const UNIT: Val = Val::Unit; + if rs.len() <= INLINE_OPERANDS { + let mut inline = [UNIT; INLINE_OPERANDS]; + for (slot, r) in inline.iter_mut().zip(rs) { + *slot = self.r(*r); + } + Operands { + inline, + len: rs.len(), + spill: Vec::new(), + } + } else { + Operands { + inline: [UNIT; INLINE_OPERANDS], + len: 0, + spill: rs.iter().map(|r| self.r(*r)).collect(), + } + } + } + + /// Read operands into an owned vector, for ops that build a value out of + /// them and would have to copy anyway. + fn read_owned(&self, rs: &[Reg]) -> Vec { rs.iter().map(|r| self.r(*r)).collect() } @@ -145,6 +218,7 @@ impl<'m, H: Host> Vm<'m, H> { return Err("out of fuel: the program did not terminate".to_string()); } self.fuel -= 1; + self.steps += 1; // Borrow the code out of the module reference, not out of `self`: // `'m` outlives this loop, so ops stay borrowed while `self` is @@ -178,7 +252,7 @@ impl<'m, H: Host> Vm<'m, H> { } End::Jmp(b, args) => { let vals = self.read(args); - self.jump(*b, &vals)?; + self.jump(*b, vals.as_slice())?; } End::Br(c, t, e) => { let target = if self.r(*c).truthy() { *t } else { *e }; @@ -197,21 +271,23 @@ impl<'m, H: Host> Vm<'m, H> { } End::Recur(args) => { let vals = self.read(args); - self.jump(BlockId(0), &vals)?; + self.jump(BlockId(0), vals.as_slice())?; } End::Tail(callee, args) => { let vals = self.read(args); - self.enter(*callee, &vals, Rc::new(Vec::new()))?; + let caps = self.no_caps.clone(); + self.enter(*callee, vals.as_slice(), caps)?; } End::TailInvoke(f, args) => { let callee = self.r(*f); let vals = self.read(args); + let vals = vals.as_slice(); // A tail call must not push a frame — that is the whole // promise — so it cannot reuse `invoke`'s path. match callee { - Val::Closure(fid, caps) => self.enter(fid, &vals, caps)?, + Val::Closure(fid, caps) => self.enter(fid, vals, caps)?, Val::Cont(k) => { - let v = vals.into_iter().next().unwrap_or(Val::Unit); + let v = vals.first().cloned().unwrap_or(Val::Unit); self.resume(&k, v)?; } other => { @@ -231,14 +307,24 @@ impl<'m, H: Host> Vm<'m, H> { /// Jump within the current function, binding the target's block params. fn jump(&mut self, b: BlockId, args: &[Val]) -> VmResult<()> { - let f = self.func_def(self.func)?; + // Borrow the param list out of the module (lifetime `'m`), not out + // of `self` — cloning it here cost an allocation on every jump, which + // for a tail-recursive loop is one per iteration. + let m = self.m; + let f = m + .funcs + .get(self.func.0 as usize) + .ok_or_else(|| alloc::format!("bad function id {}", self.func.0))?; let target = f .blocks .get(b.0 as usize) .ok_or_else(|| alloc::format!("bad block id {}", b.0))?; - let params = target.params.clone(); - for (p, v) in params.iter().zip(args.iter()) { - self.w(*p, v.clone()); + for (p, v) in target.params.iter().zip(args.iter()) { + let i = p.0 as usize; + if i >= self.regs.len() { + self.regs.resize(i + 1, Val::Unit); + } + self.regs[i] = v.clone(); } self.block = b; self.ip = 0; @@ -255,7 +341,17 @@ impl<'m, H: Host> Vm<'m, H> { // lowering numbers parameters first and the entry block inherits // them rather than being jumped to with operands. let n = (f.regs as usize).max(args.len()); - let mut regs = vec![Val::Unit; n]; + // Reclaim the outgoing register file first. A tail call replaces the + // frame rather than returning through it, so without this a + // tail-recursive loop allocates a fresh one every iteration and the + // pool never sees a buffer. (On the `call` path this is the empty + // vector left behind by the frame push, which costs nothing.) + let dead = core::mem::take(&mut self.regs); + self.recycle(dead); + + let mut regs = self.reg_pool.pop().unwrap_or_default(); + regs.clear(); + regs.resize(n, Val::Unit); for (i, v) in args.iter().enumerate() { regs[i] = v.clone(); } @@ -280,7 +376,7 @@ impl<'m, H: Host> Vm<'m, H> { block: self.block, ip: self.ip, regs: core::mem::take(&mut self.regs), - captures: core::mem::replace(&mut self.captures, Rc::new(Vec::new())), + captures: core::mem::replace(&mut self.captures, self.no_caps.clone()), ret_reg, }); if self.frames.len() > 8192 { @@ -297,7 +393,9 @@ impl<'m, H: Host> Vm<'m, H> { self.func = fr.func; self.block = fr.block; self.ip = fr.ip; - self.regs = fr.regs; + // The callee's register file is dead now; park it for the next call. + let dead = core::mem::replace(&mut self.regs, fr.regs); + self.recycle(dead); self.captures = fr.captures; if fr.ret_reg != DISCARD { self.w(Reg(fr.ret_reg), v); @@ -309,6 +407,18 @@ impl<'m, H: Host> Vm<'m, H> { Ok(()) } + /// Park a dead register file for reuse, dropping its values so they do + /// not stay alive in the pool. + fn recycle(&mut self, mut regs: Vec) { + // A zero-capacity vector is not a buffer — parking one would push a + // real buffer out of the pool and hand the next call something it + // has to grow from nothing. + if regs.capacity() > 0 && self.reg_pool.len() < REG_POOL_MAX { + regs.clear(); + self.reg_pool.push(regs); + } + } + /// Call a Loon value from Rust (a builtin taking a function, say) and /// run it to completion. fn apply(&mut self, f: &Val, args: &[Val]) -> VmResult { @@ -370,15 +480,16 @@ impl<'m, H: Host> Vm<'m, H> { } Op::Call(d, f, args) => { let vals = self.read(args); - self.call(*f, &vals, d.0, Rc::new(Vec::new()))?; + let caps = self.no_caps.clone(); + self.call(*f, vals.as_slice(), d.0, caps)?; } Op::Invoke(d, f, args) => { let callee = self.r(*f); let vals = self.read(args); match callee { - Val::Closure(fid, caps) => self.call(fid, &vals, d.0, caps)?, + Val::Closure(fid, caps) => self.call(fid, vals.as_slice(), d.0, caps)?, Val::Cont(k) => { - let v = vals.into_iter().next().unwrap_or(Val::Unit); + let v = vals.as_slice().first().cloned().unwrap_or(Val::Unit); self.resume_at(&k, v, Some(d.0))?; } other => { @@ -387,20 +498,20 @@ impl<'m, H: Host> Vm<'m, H> { } } Op::Close(d, f, caps) => { - let vals = self.read(caps); + let vals = self.read_owned(caps); self.w(*d, Val::Closure(*f, Rc::new(vals))); } Op::Vec(d, rs) => { - let vals = self.read(rs); + let vals = self.read_owned(rs); self.w(*d, Val::Vec(Rc::new(vals))); } Op::Tup(d, rs) => { - let vals = self.read(rs); + let vals = self.read_owned(rs); self.w(*d, Val::Tup(Rc::new(vals))); } Op::Set(d, rs) => { let mut vals: Vec = Vec::new(); - for v in self.read(rs) { + for v in self.read_owned(rs) { if !vals.contains(&v) { vals.push(v); } @@ -419,7 +530,7 @@ impl<'m, H: Host> Vm<'m, H> { self.w(*d, Val::Map(Rc::new(out))); } Op::Adt(d, tag, rs) => { - let vals = self.read(rs); + let vals = self.read_owned(rs); self.w(*d, Val::Adt(*tag, Rc::new(vals))); } Op::Tag(d, a) => { @@ -436,7 +547,7 @@ impl<'m, H: Host> Vm<'m, H> { } Op::Builtin(d, tag, args) => { let vals = self.read(args); - let v = self.builtin(*tag, &vals)?; + let v = self.builtin(*tag, vals.as_slice())?; self.w(*d, v); } Op::PushHandler(h, eff, o) => { @@ -463,7 +574,7 @@ impl<'m, H: Host> Vm<'m, H> { } Op::Perform(d, eff, o, args) => { let vals = self.read(args); - self.perform(*d, *eff, *o, vals)?; + self.perform(*d, *eff, *o, vals.as_slice())?; } } Ok(()) @@ -521,7 +632,7 @@ impl<'m, H: Host> Vm<'m, H> { /// Perform an effect: find the innermost handler, capture everything /// between here and its prompt as a continuation, and run the clause at /// the prompt with `resume` bound to that continuation. - fn perform(&mut self, dst: Reg, eff: StringId, o: StringId, args: Vec) -> VmResult<()> { + fn perform(&mut self, dst: Reg, eff: StringId, o: StringId, args: &[Val]) -> VmResult<()> { let found = self .handlers .iter() @@ -531,7 +642,7 @@ impl<'m, H: Host> Vm<'m, H> { let Some((hval, prompt_depth)) = found else { // Nothing in Loon handles this, so it falls through to hardware. - let v = self.hardware(eff, o, &args)?; + let v = self.hardware(eff, o, args)?; self.w(dst, v); return Ok(()); }; @@ -558,7 +669,7 @@ impl<'m, H: Host> Vm<'m, H> { block: self.block, ip: self.ip, regs: core::mem::take(&mut self.regs), - captures: core::mem::replace(&mut self.captures, Rc::new(Vec::new())), + captures: core::mem::replace(&mut self.captures, self.no_caps.clone()), perform_dst: dst.0, prompt_handlers, }; @@ -581,7 +692,7 @@ impl<'m, H: Host> Vm<'m, H> { match hval { Val::Closure(fid, caps) => { let mut call_args = vec![k]; - call_args.extend(args); + call_args.extend_from_slice(args); self.call(fid, &call_args, handle_ret, caps) } other => Err(alloc::format!( @@ -613,7 +724,7 @@ impl<'m, H: Host> Vm<'m, H> { block: self.block, ip: self.ip, regs: core::mem::take(&mut self.regs), - captures: core::mem::replace(&mut self.captures, Rc::new(Vec::new())), + captures: core::mem::replace(&mut self.captures, self.no_caps.clone()), ret_reg: dst, }); } diff --git a/crates/loon-kernel/src/heap.rs b/crates/loon-kernel/src/heap.rs index 80805b4..0073970 100644 --- a/crates/loon-kernel/src/heap.rs +++ b/crates/loon-kernel/src/heap.rs @@ -20,6 +20,9 @@ const MIN_BLOCK: usize = core::mem::size_of::(); pub struct Heap { free: UnsafeCell<*mut Block>, + /// Allocation count. Wall-clock under emulation is noisy; this is not, + /// so it is what optimisation work should be judged against. + allocs: core::sync::atomic::AtomicU64, } // Single hart, interrupts off: no concurrent access exists. @@ -29,9 +32,14 @@ impl Heap { pub const fn new() -> Self { Heap { free: UnsafeCell::new(ptr::null_mut()), + allocs: core::sync::atomic::AtomicU64::new(0), } } + pub fn allocs(&self) -> u64 { + self.allocs.load(core::sync::atomic::Ordering::Relaxed) + } + /// # Safety /// `start..start+size` must be untouched, writable RAM that outlives all /// allocations, and this must be called exactly once. @@ -73,6 +81,8 @@ impl Heap { unsafe impl GlobalAlloc for Heap { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + self.allocs + .fetch_add(1, core::sync::atomic::Ordering::Relaxed); let align = layout.align().max(core::mem::align_of::()); let size = align_up(layout.size().max(MIN_BLOCK), core::mem::align_of::()); diff --git a/crates/loon-kernel/src/main.rs b/crates/loon-kernel/src/main.rs index 16af00b..91d26e7 100644 --- a/crates/loon-kernel/src/main.rs +++ b/crates/loon-kernel/src/main.rs @@ -73,6 +73,16 @@ pub extern "C" fn kmain(hart: usize, dtb: usize) -> ! { println!("init image {} bytes", image.len()); println!(); + for (name, img) in [ + ("loop ", include_bytes!(env!("LOON_LOOP_IMAGE")).as_slice()), + ("bench", include_bytes!(env!("LOON_BENCH_IMAGE")).as_slice()), + ] { + if let Err(e) = run_bench_named(name, img) { + println!("{name} failed: {e}"); + } + } + println!(); + let t0 = now(); match run_init(image) { Ok(()) => { @@ -130,6 +140,29 @@ fn run_init(image: &[u8]) -> Result<(), alloc::string::String> { Ok(()) } +/// Time the interpreter on an IO-free workload and report ns per dispatched +/// op. Steps come from the VM's own loop counter, so this measures the +/// interpreter rather than the console. +fn run_bench_named(name: &str, image: &[u8]) -> Result<(), alloc::string::String> { + let module = eir::decode::decode(image)?; + let mut machine = Machine; + let mut vm = eir::vm::Vm::new(&module, &mut machine).with_fuel(2_000_000_000); + + let t = now(); + let a0 = HEAP.allocs(); + vm.run()?; + let us = micros_since(t); + let allocs = HEAP.allocs() - a0; + let steps = vm.steps(); + + println!( + "{name}: {steps} ops in {us} us = {} ns/op, {allocs} allocs = {} per 100 ops", + (us * 1000).checked_div(steps).unwrap_or(0), + (allocs * 100).checked_div(steps).unwrap_or(0), + ); + Ok(()) +} + #[panic_handler] fn panic(info: &core::panic::PanicInfo) -> ! { println!("\nkernel panic: {info}"); diff --git a/docs/plans/2026-07-01-loon-os.md b/docs/plans/2026-07-01-loon-os.md index cb19c4d..e917a1c 100644 --- a/docs/plans/2026-07-01-loon-os.md +++ b/docs/plans/2026-07-01-loon-os.md @@ -442,12 +442,27 @@ call cost. > **Not yet.** Preemption (no timer interrupt — cooperative only, so a pure > loop owns the machine), SMP, static handler resolution, and most of the > builtin set (missing ones raise a loud error naming the builtin, never a -> silent `()`). Performance is untuned and it shows: ~0.9 µs per interpreted -> op, because a call allocates a register file and each op allocates an -> operand vector on a first-fit allocator costing ~0.5 µs per allocation. -> "Syscall cost = function call cost" is not yet demonstrated — a function -> call is itself the expensive thing right now. Boot-to-init-exit for the -> demo program is ~30 ms, essentially all interpreter. +> silent `()`). +> +> **Perf, measured (2026-08-18).** ~500 ns per interpreted op under emulation, +> against ~7 ns for a minimal native dispatch loop on the same emulator. Two +> findings, one of which corrects the note above as first written: +> +> - `opt-level` dominates. The crate was scaffolded `opt-level = "z"`, which +> suppresses the inlining a dispatch loop lives on; moving to `3` was worth +> **3.3x**, more than everything else combined. +> - **Allocation was never the bottleneck.** Pooling register files and +> keeping operands off the heap cut the benchmark from 89,858 allocations +> to 47 and bought no measurable time. The earlier claim that allocation +> was the cost was wrong. The change is kept for near-constant-memory +> interpretation — valuable in a kernel with no OOM killer — not for speed, +> and a slab allocator is not worth building because nothing is left to +> allocate. +> +> "Syscall cost = function call cost" remains undemonstrated. Note that +> `loop.oo` (tail recursion, no frame pushes) and `bench.oo` (call-heavy) cost +> about the same per op, so the remaining gap is per-op dispatch overhead +> rather than calling. ### Phase 4 — Bare metal (optional grind) From f68f134fbacde8ed0ea5c3589d01ee4b35df54a6 Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Tue, 18 Aug 2026 11:17:03 -0400 Subject: [PATCH 2/2] The kernel draws a fractal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boot/mandel.oo renders the Mandelbrot set in ASCII, and the unikernel runs it after init. Every float op, closure application and string concat goes through the bare-metal interpreter — no libm, no libc, no OS — and the output is byte-identical to the host, which `make check` now enforces alongside init. So it is gratuitous, but it is also the float path's parity check in disguise. Adds Float/Int intrinsics to the kernel builtins on the way (int<->float conversion, string parse via core's parser to match the host exactly). Also surfaced a small language gap: the checker types `float` as Str -> Float even though the runtime accepts Int, so int->float needs the `[float [str x]]` detour. Worth fixing in the checker; noted, not done here. Co-Authored-By: Claude Opus 5 --- crates/loon-kernel/Makefile | 9 +++++++-- crates/loon-kernel/README.md | 4 +++- crates/loon-kernel/boot/mandel.oo | 32 +++++++++++++++++++++++++++++++ crates/loon-kernel/build.rs | 1 + crates/loon-kernel/src/eir/vm.rs | 30 +++++++++++++++++++++++++++++ crates/loon-kernel/src/main.rs | 7 +++++++ 6 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 crates/loon-kernel/boot/mandel.oo diff --git a/crates/loon-kernel/Makefile b/crates/loon-kernel/Makefile index 8abda5c..fe1e8b9 100644 --- a/crates/loon-kernel/Makefile +++ b/crates/loon-kernel/Makefile @@ -21,12 +21,17 @@ run: build host: cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/init.oo -# Boot, and prove the machine agrees with the host byte for byte. +# Boot, and prove the machine agrees with the host byte for byte — for init, +# and for the fractal (which is the float path's parity check in disguise). check: build @$(QEMU) $(QFLAGS) -serial mon:stdio -kernel $(KERNEL) < /dev/null 2>/dev/null \ - | sed -n '/^hello from loon/,/^init done/p' | tr -d '\r' > /tmp/loon-metal.txt + | tr -d '\r' > /tmp/loon-metal-all.txt + @sed -n '/^hello from loon/,/^init done/p' /tmp/loon-metal-all.txt > /tmp/loon-metal.txt + @sed -n '/^@@@@@@@@@@@@%/,/^@@@@@@@@@@@%%%%%%%%####/p' /tmp/loon-metal-all.txt > /tmp/loon-metal-mandel.txt @cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/init.oo 2>/dev/null > /tmp/loon-host.txt + @cd ../.. && cargo run -q -p loon-cli -- run crates/loon-kernel/boot/mandel.oo 2>/dev/null > /tmp/loon-host-mandel.txt @diff /tmp/loon-host.txt /tmp/loon-metal.txt \ + && diff /tmp/loon-host-mandel.txt /tmp/loon-metal-mandel.txt \ && echo "ok: identical output on the host and on bare metal" clean: diff --git a/crates/loon-kernel/README.md b/crates/loon-kernel/README.md index 4ebb653..b729aaa 100644 --- a/crates/loon-kernel/README.md +++ b/crates/loon-kernel/README.md @@ -24,6 +24,7 @@ make check # boot it and diff the two | `src/sbi.rs` | the slice of SBI we need (power off) | | `src/eir/` | boot-image decoder and the EIR interpreter | | `boot/init.oo` | the init program — ordinary Loon | +| `boot/mandel.oo` | a Mandelbrot set, because a kernel that boots should get to do one gratuitous thing | The host toolchain is not in this crate's build graph. `build.rs` shells out to `loon image`, which compiles `boot/init.oo` to a boot image; the kernel @@ -32,7 +33,8 @@ checker, ownership, lowering — stays on the host, where it belongs. ## Why the output has to match -`make check` diffs the machine against the host. That diff is the point of +`make check` diffs the machine against the host — init *and* the fractal, +which doubles as the float path's parity check. That diff is the point of the exercise: the same program, the same effects, two entirely different bottom halves. If they ever disagree, one of the two runtimes is wrong about what Loon means, and a language whose semantics depend on where it runs is diff --git a/crates/loon-kernel/boot/mandel.oo b/crates/loon-kernel/boot/mandel.oo new file mode 100644 index 0000000..8137869 --- /dev/null +++ b/crates/loon-kernel/boot/mandel.oo @@ -0,0 +1,32 @@ +; The kernel draws a fractal. Every float op, every closure application, +; every string concat here runs on the bare-metal interpreter — no libm, +; no libc, no OS. It is a Mandelbrot set because a kernel that boots +; should get to do at least one gratuitous thing. +; +; loon run crates/loon-kernel/boot/mandel.oo ; host +; make -C crates/loon-kernel run ; machine (runs after init) + +[fn escape [zr zi cr ci n] + [if [or [> [+ [* zr zr] [* zi zi]] 4.0] [>= n 24]] + n + [escape [+ [- [* zr zr] [* zi zi]] cr] [+ [* 2.0 [* zr zi]] ci] cr ci [+ n 1]]]] + +[fn shade [n] + [if [>= n 24] " " + [if [< n 2] "@" + [if [< n 3] "%" + [if [< n 4] "#" + [if [< n 6] "*" + [if [< n 9] "+" + [if [< n 13] "=" + [if [< n 18] "-" "."]]]]]]]]] + +[fn pixel [x y] + [let cr [- [* [float [str x]] 0.0375] 2.1]] + [let ci [- [* [float [str y]] 0.09] 1.1]] + [shade [escape 0.0 0.0 cr ci 0]]] + +[fn row [y] [join [map [range 0 80] [fn [x] [pixel x y]]] ""]] + +[fn main [] + [each [range 0 25] [fn [y] [println [row y]]]]] diff --git a/crates/loon-kernel/build.rs b/crates/loon-kernel/build.rs index 19cb6cd..bde3783 100644 --- a/crates/loon-kernel/build.rs +++ b/crates/loon-kernel/build.rs @@ -17,6 +17,7 @@ fn main() { ("init", "LOON_BOOT_IMAGE"), ("bench", "LOON_BENCH_IMAGE"), ("loop", "LOON_LOOP_IMAGE"), + ("mandel", "LOON_MANDEL_IMAGE"), ] { let src = manifest.join(format!("boot/{name}.oo")); let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join(format!("{name}.img")); diff --git a/crates/loon-kernel/src/eir/vm.rs b/crates/loon-kernel/src/eir/vm.rs index e8c9be2..467148b 100644 --- a/crates/loon-kernel/src/eir/vm.rs +++ b/crates/loon-kernel/src/eir/vm.rs @@ -936,6 +936,36 @@ impl<'m, H: Host> Vm<'m, H> { v => seq(&v).map(|x| x.is_empty()).unwrap_or(false), }), "Not" => Val::Bool(!a0().truthy()), + "Float" => match a0() { + Val::Float(f) => Val::Float(f), + Val::Int(n) => Val::Float(n as f64), + Val::Str(s) => Val::Float( + s.trim() + .parse::() + .map_err(|_| alloc::format!("cannot parse '{s}' as a float"))?, + ), + v => { + return Err(alloc::format!( + "cannot convert a {} to a float", + v.type_name() + )) + } + }, + "Int" => match a0() { + Val::Int(n) => Val::Int(n), + Val::Float(f) => Val::Int(f as i64), + Val::Str(s) => Val::Int( + s.trim() + .parse::() + .map_err(|_| alloc::format!("cannot parse '{s}' as an int"))?, + ), + v => { + return Err(alloc::format!( + "cannot convert a {} to an int", + v.type_name() + )) + } + }, "TypeOf" => Val::Str(Rc::new(a0().type_name().to_string())), "SomeP" => Val::Bool(!matches!(a0(), Val::Unit)), "NoneP" => Val::Bool(matches!(a0(), Val::Unit)), diff --git a/crates/loon-kernel/src/main.rs b/crates/loon-kernel/src/main.rs index 91d26e7..9c77b8b 100644 --- a/crates/loon-kernel/src/main.rs +++ b/crates/loon-kernel/src/main.rs @@ -83,6 +83,13 @@ pub extern "C" fn kmain(hart: usize, dtb: usize) -> ! { } println!(); + // Gratuitous. A kernel that boots should get to do one fun thing. + println!(); + if let Err(e) = run_init(include_bytes!(env!("LOON_MANDEL_IMAGE"))) { + println!("mandel failed: {e}"); + } + println!(); + let t0 = now(); match run_init(image) { Ok(()) => {