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/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 aee5e79..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 @@ -46,6 +48,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 +97,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/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 5f6e022..bde3783 100644 --- a/crates/loon-kernel/build.rs +++ b/crates/loon-kernel/build.rs @@ -5,28 +5,39 @@ //! 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"), + ("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")); + 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 +49,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..467148b 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, }); } @@ -825,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/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..9c77b8b 100644 --- a/crates/loon-kernel/src/main.rs +++ b/crates/loon-kernel/src/main.rs @@ -73,6 +73,23 @@ 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!(); + + // 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(()) => { @@ -130,6 +147,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)