Tail-Call Interpreters in Rust - Jimmy Ostler
Tail-Call Interpreters in Rust
01 Aug 2026
Jimmy Ostler
Word Count: 1636
Reading Time: 9 Min
Recently, I came across this post<br>about different styles of VM dispatch as I was searching for ways to improve my ternary<br>project. I had heard of tail-call interpretation, though my original source of inspiration took some<br>time for me to re-find. This post, however, gave an excellent breakdown about several different styles of<br>VM dispatch in Scala. I decided to implement these in Rust (including several variations more relevant to my project)<br>as a fun experiment, and benchmark them to measure how they differ. I'll go over 2 versions - one, meant to<br>emulate Noel's Scala, the other, meant to utilize Rust's strengths with a more complicated and traditional<br>register machine.
Tail-Calls
Tail-call interpretation refers to the technique where some recursion can be turned into a jump during compilation,<br>removing the need to allocate a new stack frame.<br>It's extremely useful for functional languages to keep stack sizes down, such as Scala, but most compilers tend to use it.<br>If you want to learn more, I highly recommend checking out Noel's excellent article above. When compiling with high optimization,<br>Rust also performs this, and the unstable feature explicit_tail_calls lets us directly tell the compiler to<br>perform the optimization or error.
Stack Machine (Noel's Machine)
The simplest machine we can easily work with here is a stack machine with 5 instructions, represented in Rust as so:
enum ByteCode {<br>Lit(f64),<br>Add,<br>Sub,<br>Mul,<br>Div<br>Essentially identical to Noel's Scala. Since this is a stack machine, the Lit (literal) instruction pushes a value<br>on the stack; arithmetic instructions pop their operands, and push the resulting value back onto the stack.
Dispatch
As a control, switch dispatch makes the most sense. We simply create an array of bytecode, loop over it in a match statement,<br>and execute it.
NOTE: I decided to use some strange decisions to match up with the Scala.<br>These include the usage of `static mut` and `unsafe` as opposed to manually<br>creating closures, though I did that in a sense anyways. I do NOT endorse<br>writing Rust this way.Switch Dispatch
const STACK_SIZE: usize = 32;<br>// Our stack<br>static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE];
// The list of instructions to execute<br>static mut INSTRS: &[Instr] = /* { [Lit(4.0), Lit(3.0)... etc] } */;
// We pass the stack pointer and instruction pointer to `dispatch`<br>pub fn dispatch(sp: usize, ip: usize) -> f32 {<br>unsafe {<br>if ip == INSTRS.len() {<br>STACK[sp - 1]<br>} else {<br>match INSTRS[ip] {<br>Instr::Lit(value) => {<br>STACK[sp] = value;<br>become dispatch(sp + 1, ip + 1)<br>},<br>Instr::Add => {<br>let a = STACK[sp - 2];<br>let b = STACK[sp - 1];<br>STACK[sp - 2] = a + b;<br>become dispatch(sp - 1, ip + 1)<br>},<br>Instr::Sub => {<br>let a = STACK[sp - 2];<br>let b = STACK[sp - 1];<br>STACK[sp - 2] = a - b;<br>become dispatch(sp - 1, ip + 1)<br>},<br>Instr::Mul => {<br>let a = STACK[sp - 2];<br>let b = STACK[sp - 1];<br>STACK[sp - 2] = a * b;<br>become dispatch(sp - 1, ip + 1)<br>},<br>Instr::Div => {<br>let a = STACK[sp - 2];<br>let b = STACK[sp - 1];<br>STACK[sp - 2] = a / b;<br>become dispatch(sp - 1, ip + 1)<br>},<br>Here we can see our entire logic - a large recursive function that calls itself for every instruction. Since we used the<br>become keyword, we know our recursion won't lead to a stack overflow.<br>This is a nice and simple strategy! Nothing too complicated here.
Subroutine Dispatch
We next do subroutine threading, where we replace the match statement. Instead of an enum,<br>we have to implement our instructions as a struct that can be called by implementing the<br>Fn() trait. This means we can call<br>a dynamic &dyn Fn(), regardless of the underlying struct.
Our bytecode now looks like this (some parts omitted for brevity):
// Now, our instructions are `&dyn Fn()`, so we can use dynamic dispatch<br>// to call different instructions without knowing what they are.<br>static mut INSTRS: &[&dyn Fn() -> ()] = /*[&Lit, &Add... etc]*/;
static mut SP: usize = 0;<br>const STACK_SIZE: usize = 32;<br>static mut STACK: &mut [f32] = &mut [0.0; STACK_SIZE];
struct Lit(f32);<br>struct Add;<br>struct Sub;<br>struct Mul;<br>struct Div;
impl Fn()> for Lit {<br>extern "rust-call" fn call(&self, _args: ()) -> Self::Output {<br>unsafe {<br>STACK[SP] = self.0;<br>SP += 1;
impl Fn()> for Add {<br>extern "rust-call" fn call(&self, _args: ()) -> Self::Output {<br>unsafe {<br>let a = STACK[SP - 1];<br>let b = STACK[SP - 2];<br>STACK[SP - 2] = a + b;<br>SP -= 1;
impl Fn()> for Sub {<br>extern "rust-call" fn call(&self, _args: ()) -> Self::Output {<br>unsafe {<br>let a = STACK[SP - 1];<br>let b = STACK[SP - 2];<br>STACK[SP - 2] = a - b;<br>SP -= 1;
impl Fn()> for Mul {<br>extern "rust-call" fn call(&self, _args: ()) -> Self::Output {<br>unsafe {<br>let a = STACK[SP - 1];<br>let b = STACK[SP - 2];<br>STACK[SP - 2] = a * b;<br>SP -= 1;
impl Fn()> for Div {<br>extern "rust-call" fn call(&self, _args: ()) -> Self::Output {<br>unsafe {<br>let a = STACK[SP - 1];<br>let b = STACK[SP - 2];<br>STACK[SP - 2] = a * b;<br>SP -=...