The Bytecode VM

High-performance execution

The tree-walking interpreter is easy to understand but not the fastest. For performance-critical code, Bloom has an optional bytecode virtual machine (VM) that can be much faster.

Why Bytecode?

Walking a tree involves lots of pointer chasing and method dispatch. Bytecode is a flat array of numbers — the VM just reads instructions sequentially, which is much faster.

Tree-Walking
+
1
2
Jump between nodes (slower)
Bytecode VM
CONST1CONST2ADD
→ sequential read
Simple array scan (faster)

How It Works

The bytecode system has two parts:

  1. Compiler — Converts AST to bytecode instructions
  2. Virtual Machine — Executes those instructions

The Stack

The VM uses a stack to hold values. Operations pop values from the stack, compute, and push results back.

Expression: 1 + 2
CONST 1    // Push 1 onto stack         Stack: [1]
CONST 2    // Push 2 onto stack         Stack: [1, 2]
ADD        // Pop 2, pop 1, push 1+2    Stack: [3]

Bytecode Instructions

Each instruction is an opcode (operation code), sometimes followed by arguments:

Addr Opcode Operand
0 CONST 0 (value: 1)
2 CONST 1 (value: 2)
4 ADD

The CONST instruction takes an index into a constant pool — an array of literal values. This keeps the bytecode compact.

Key Opcodes

Category
Opcodes
Purpose
Stack
CONST, POP, DUP
Push/remove values
Variables
LOAD_LOCAL, STORE_LOCAL
Read/write local vars
Globals
LOAD_GLOBAL, STORE_GLOBAL
Read/write global vars
Math
ADD, SUB, MUL, DIV, MOD, NEG
Arithmetic operations
Compare
EQ, NE, LT, LE, GT, GE
Comparisons
Jumps
JUMP, JUMP_IF_FALSE, LOOP
Control flow
Functions
CALL, CALL_NATIVE, RETURN
Function calls
Collections
NEW_ARRAY, GET_INDEX, SET_INDEX
Array/object access
Iteration
RANGE, ITER_INIT, ITER_NEXT
Loop support

Compiling a For Loop

Let's see how a loop becomes bytecode:

Source
for i in 0..3 {
  print(i)
}
Addr Opcode Comment
0 CONST 0 ; push start value
2 CONST 3 ; push end value
4 RANGE ; create range object
5 ITER_INIT slot 0 ; store iterator
7 ITER_NEXT slot 0, slot 1 ; get next → i
10 JUMP_IF_FALSE → 20 ; done if exhausted
13 LOAD_LOCAL slot 1 ; push i
15 CALL_NATIVE "print", 1 arg
18 LOOP → 7 ; jump back to ITER_NEXT
20 (end)

The green rows are the loop body — executed 3 times. LOOP is a backward jump that repeats the body.

Superinstructions

Common instruction sequences are fused into single superinstructions for speed:

Pattern
Superinstruction
Speedup
LOAD a; LOAD b; ADD
ADD_LOCALS a, b
~2x
LOAD a; CONST n; LT; JUMP_IF_FALSE
LOAD_LOCAL_CONST_LT_JUMP
~3x
LOAD a; CONST 1; ADD; STORE a
INC_LOCAL a
~3x

The compiler recognizes these patterns and emits the optimized version.

Local Variables

Inside functions, variables use numbered slots instead of names. No hash table lookup needed!

fn example(a, b) {   // a = slot 0, b = slot 1
  let c = a + b      // c = slot 2
  return c
}

// Compiled to:
LOAD_LOCAL 0     // push a
LOAD_LOCAL 1     // push b
ADD              // a + b
STORE_LOCAL 2    // store to c
LOAD_LOCAL 2     // push c
RETURN           // return top of stack

Calling Native Functions

Native functions (like circle) use a dispatch table for O(1) lookup:

// In the VM:
case OpCode.CALL_NATIVE: {
  const funcIndex = code[ip++]
  const argCount = code[ip++]
  const func = nativeFunctions[funcIndex]  // Direct array access!
  // ... pop args, call func, push result
}

Performance

The bytecode VM can be 10-60x faster than the tree-walking interpreter for computation-heavy code.

When to use bytecode Enable bytecode for sketches with lots of math, recursion, or loops. It shines for fractals, particle systems, and complex animations.
Limitations Closures don't work correctly in the bytecode VM. If your code uses closures (functions that capture variables from outer scopes), stick with the interpreter.

In the Source Code

The bytecode system lives in src/lang/bytecode.ts: