Skip to content

Suraj370/rust-bytecode-vm

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Pyre

A bytecode compiler and stack-based VM for a custom programming language, written in Rust from scratch — no parser generators, no existing VMs, no GC libraries.

fun make_counter() -> Fun() -> Int {
    var count: Int = 0;
    return fun() -> Int {
        count = count + 1;
        return count;
    };
}

fun main() -> Unit {
    val c = make_counter();
    print("{c()}");   // 1
    print("{c()}");   // 2
    print("{c()}");   // 3
}

Pipeline

source text
    │
    ▼
Lexer          token stream, interpolated strings resolved
    │
    ▼
Pratt Parser   AST with line/col error spans
    │
    ▼
Compiler       bytecode + constant pool, upvalue descriptors
    │
    ▼
Stack VM       call frames, open→closed upvalues, mark-sweep GC

Language features

  • Lexical scoping with val (immutable) and var (mutable)
  • First-class functions and closures with correct upvalue capture
  • Coroutinescoro/yield/resume with O(1) stack-swap context switching
  • Modulesmodule/import/export with compile-time visibility enforcement
  • Tail-call optimisation — explicit return f(...) in tail position, no stack growth
  • String interpolation"hello {name}, you are {age} years old"
  • Mark-sweep GC with stress mode (collect on every allocation)

Build

cargo build --release

Run a program:

./target/release/pyre examples/hello.pyre

Flags:

Flag Effect
--dis Print disassembled bytecode and exit
--trace Print each instruction + stack to stderr before executing
--gc-stress Collect garbage before every allocation

REPL (no file argument):

./target/release/pyre

Tests

bash tests/run_tests.sh

Expected output is embedded as comments in each test file — no separate fixture files:

// expected output:
// Hello, Pyre!

fun main() -> Unit {
    print("Hello, Pyre!");
}

Browser playground

The interpreter compiles to WebAssembly via wasm-pack:

wasm-pack build --target web --out-dir playground/pkg --features wasm
cd playground && python -m http.server 8080

Open http://localhost:8080. Six built-in examples, Ctrl+Enter to run.


Modules

// math.pyre
module math;

export fun square(n: Int) -> Int { return n * n; }
export fun abs(n: Int)    -> Int { return n * sign(n); }

fun sign(n: Int) -> Int {   // private — compile error if accessed from outside
    if n > 0 { return 1; }
    if n < 0 { return -1; }
    return 0;
}
// main.pyre
import math;

fun main() -> Unit {
    print("{math.square(5)}");   // 25
    print("{math.abs(-3)}");     // 3
    // math.sign(-1)             // compile error: 'sign' is private to module 'math'
}

Coroutines

fun make_range(lo: Int, hi: Int) -> Coro() -> Int {
    return coro() -> Int {
        var i: Int = lo;
        while i < hi { yield i; i = i + 1; }
    };
}

fun main() -> Unit {
    val r = make_range(0, 3);
    print("{resume r}");   // 0
    print("{resume r}");   // 1
    print("{resume r}");   // 2
}

Context switching is O(1) — resume and yield swap stack ownership via std::mem::swap, no copying.


Benchmark vs CPython 3.14

Benchmark CPython Pyre
fib(30) × 100k 55ms 370ms 6.7× slower
sum(1..1000) × 10k 193ms 521ms 2.7× slower
closure counter × 500k 38ms 90ms 2.4× slower

Pyre is slower because both are interpreters — the gap is CPython's adaptive specialization (integer ops become direct C after ~8 executions) vs Pyre's generic opcode dispatch. Closing it would require specialized opcodes or a JIT.


Key design decisions

Pratt parser over grammar tables — operator precedence and associativity fall out of binding-power numbers assigned per token. Adding a new operator is one line.

Stack VM over register VM — simpler code generation: every expression result goes on the stack, no register allocation needed. Costs more instructions per expression but keeps the compiler straightforward.

Open → closed upvalues — captured variables start as stack slots (open). When the enclosing scope exits, they are promoted to heap-allocated cells (closed). Multiple closures sharing one variable all point to the same cell.

std::mem::swap coroutines — each coroutine owns its stack and frames in a GcCoroutine struct. resume swaps the VM's active context with the coroutine's saved context in O(1). No copying, no OS threads, no async executor.

Compile-time module visibility — the compiler builds an export table per module and rejects private-function access before any bytecode is emitted, not at runtime.

About

Bytecode compiler and stack-based VM for a custom language, written in Rust from scratch

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors