Skip to content

Latest commit

 

History

History
217 lines (178 loc) · 9.93 KB

bytecode.md

File metadata and controls

217 lines (178 loc) · 9.93 KB

Bytecode

An experimental additional emission target alongside the source-code targets (see emit.md for the roster). Instead of emitting target-language source, emit a typed stack-based bytecode and execute it in a VM that reuses the existing Rust runtime.

Source: src/bytecode/ (format types, VM, IR→bytecode walker), tests/bytecode_format.rs, tests/bytecode_vm.rs, tests/bytecode_emit.rs.

Status: experimental, parked. The module is consumed only by the tests/bytecode_*.rs suites — it is not reachable from any CLI target (no BuildTarget or profile::Target variant exists for it). M1 (format), M2 (minimal VM), and M3a (IR→bytecode walker, src/bytecode/walker.rs) have landed on main. The walker covers the subset of ExprNode whose lowering maps cleanly to the VM's opcodes: Lit, Var, Let, Seq, If, Assign to LValue::Var, Send for arithmetic/comparison on Ty::Int receivers, and Bool-typed BoolOp (short-circuit via branches); it also carries the user-function declaration hooks (declare_user_fn / begin_user_fn_body / end_user_fn_body) that pair with the VM's CallUser. Everything else returns WalkError::NotYetSupported until the corresponding M3b+ work lands.

Phase B (source-code targets at parity) was the originally-cited gate for resuming Phase C work. The universal post-lowering IR (LibraryClass | LibraryFunction) that Phase B waited on has since landed across the source-code targets (Python's per-artifact emitter is the remaining exception — see emit.md). The bytecode emitter will consume the same LibraryClass shape every other thin emitter sees, so resuming Phase C is now gated on picking the work up rather than on the shape stabilizing.

Why a bytecode target

The existing source-code emitters each produce source code compiled or interpreted by the target language's toolchain. A bytecode target changes shape in three ways:

  • The artifact is binary, not source code.
  • The execution engine ships as part of roundhouse itself (or a thin companion binary), not the target language's runtime.
  • The whole path — ingest → analyze → lower → emit-bytecode → VM — can live in one process without a separate compile step.

What that buys, if it works end-to-end:

  • rails server-class dev loop. Change a .rb file → re-emit bytecode in milliseconds → VM reloads. No cargo build between edits.
  • Smaller cold start and image. No Rust toolchain, no bundled JS runtime; one roundhouse-shaped binary plus per-app bytecode.
  • A third benchmark point. Ruby/Rails and AOT Rust bracket the performance envelope; bytecode-plus-VM measures the interpreted middle. The answer informs whether a JIT tier is worth pursuing.
  • Static-dispatch-all-the-way-down. Because the analyzer has already resolved every call, the VM never does dynamic method lookup and the allocator can be arena-per-request with no general tracing GC.

The plan

Phase A (framework) and Phase C (emitter + integration) were originally separated by Phase B (complete the existing targets first), so that lifts from bringing each target to 5/5 parity could benefit the bytecode emitter the same way they benefit each other. The LibraryClass-shape migration Phase B waited on has landed (the Go and Elixir rewrites shipped; Python's per-artifact emitter is the remaining exception). Phase C is parked by priority, not by a missing shape; M3a walker is landed, M3b+ deferred.

# Milestone Phase Status
M1 Bytecode format + roundtrip tests A ✅ landed
M2 Minimal VM: arithmetic, locals, branches A ✅ landed
— Variant experiments (stack vs register, dispatch strategies) A ⏳ parked
— All targets migrated to LibraryClass-shape thin emitters B ✅ shipped (Python remains per-artifact — see emit.md)
M3 Bytecode emitter for tiny-blog C ⏳ parked
M4 VM runs one controller action end-to-end C ⏳ parked
M5 tiny-blog controller tests pass through VM C ⏳ parked
M6 real-blog controller tests pass through VM C ⏳ parked
M7 scripts/compare bytecode hits 5/5 on real-blog C ⏳ parked
M8 Benchmark table: Rails vs bytecode VM vs AOT Rust D ⏳ parked

Each milestone produces something demonstrable and non-regressable once green, matching the forcing-function methodology the rest of the pipeline follows.

Format design

Three baseline choices in the M1 opcode set. All three are measurable rather than dogmatic; the variant-experiments slot between M2 and M3 revisits each empirically if the benchmark data argues for it.

Stack-based. Instructions operate on an implicit operand stack. Easier to emit from the tree IR than a register machine. The register-based alternative (typical ~26% speedup per Shi/Casey/Ertl/Gregg, Virtual Machine Showdown: Stack Versus Registers, VEE 2005) is an explicit follow-up experiment, not a deferred decision.

Typed opcodes. LoadI64 / AddI64 / ConcatStr rather than polymorphic Load / Add with runtime type tags. The analyzer already resolves every expression's type, so the emitter picks the right opcode and the VM never needs to type-check at runtime.

Direct runtime dispatch. CallRt carries an RtFnId that indexes into Program::runtime_fns; the VM resolves each name to a Rust function pointer once at load time. No per-call hashmap lookup.

Format (M1)

Defined in src/bytecode/format.rs.

Top-level container:

pub struct Program {
    pub format_version: u32,
    pub string_pool: Vec<String>,
    pub symbol_pool: Vec<String>,
    pub runtime_fns: Vec<String>,
    pub user_fns: Vec<UserFn>,
    pub code: Vec<Op>,
}

The opcode set (see the Op enum in format.rs for the authoritative list) spans literal pushes, locals, integer arithmetic, string concatenation, typed comparisons, control flow (jumps carry signed i32 offsets relative to the next instruction), calls (user and runtime), stack manipulation, collections, and string interpolation.

The serialization format itself is deferred. Types derive serde::{Serialize, Deserialize}, so any serde-compatible format works. M1 tests use serde_json for legibility; later milestones swap in a binary format (bincode, postcard, or hand-rolled) once VM load time matters.

VM (M2)

Defined in src/bytecode/vm.rs.

Value representation — tagged enum Value::Int | Float | Bool | Str | Nil. Alternatives (separate stacks per type, universal word slots) are variant-experiment material; this is the baseline.

State — operand stack (Vec<Value>), a call-frame stack (a frames vec of Frame { locals, return_pc }), and a program counter (usize). The operand stack is shared across frames — arguments flow in through it. CallUser pushes a frame and Return pops it, so user-function calls execute for real; only the opcodes below remain deferred.

Dispatch — match on Op inside a loop. Each Op clones per iteration (a memcpy of ~16 bytes; Op has no heap fields) to avoid the borrow dance of matching on a reference while also mutating pc. Computed-goto / direct-threaded dispatch is one of the variant experiments.

Deferred opcodes — CallRt, ConcatStr, NewArray, NewHash, IndexLoad, IndexStore, InterpStr return VmError::NotYetSupported(name) rather than panicking (CallUser graduated to a real implementation). Tests catch accidental reliance before the emitter lands.

Variant experiments (between M2 and M3)

Once the framework is in place the cost of picking any specific design is low: write both, measure both, keep the winner.

Variant Hypothesis
stack_typed_v1 Baseline. What M1/M2 currently build.
stack_poly_v1 Polymorphic opcodes with type tags — smaller bytecode, more runtime dispatch.
register_typed_v1 Register-based, typed — harder to emit, faster to execute.
threaded_dispatch Same bytecode as baseline but computed-goto / direct-threading VM.
minimal_runtime_calls No CALL_RT opcode, everything inlined — isolates the runtime-call overhead itself.

Each is a few days of work once the framework exists. Each answers a specific question. Losing variants would keep their measurements published (a dedicated home under bench/ is aspirational — none exists yet); the winner advances to M3.

Variant-picking benchmarks are cheap and fast (microbenchmark + one real-blog endpoint at fixed concurrency, ~1 minute per variant) and live in a separate rig from the publication benchmarks (Rails vs roundhouse, hour-long runs, pinned-governor Hetzner host).

What's still to decide

  • Where the VM ultimately ships. src/bytecode/vm.rs is where M2 lives today (compiles as part of the roundhouse crate, tests run in the repo). For M3+, the VM becomes the thing a user invokes to run a compiled bytecode file — either extract to a new vm/ dir under runtime/rust/ and copy like the other runtime files, or ship a roundhouse-vm binary in the roundhouse crate. Clean options either way; defer until the decision has consequences.
  • On-disk format. serde_json today is a placeholder for tests; a binary format is wanted before benchmarks. bincode is a one-line swap once the format stabilizes.
  • Whether to publish interpreter numbers separately. Once M8 lands, the benchmark table has three columns: Rails, bytecode VM, AOT Rust. The numbers decide whether interpreter-mode is a deployment choice or purely research.

See also

  • emit.md — the source-code emitters that share the analyzer, lower, and runtime integration this target will consume.
  • lower.md — target-neutral IR. The bytecode emitter will consume the same LoweredAction, LoweredPersistence, LoweredValidation, ViewHelperKind as every other target.
  • runtime.md — per-target runtime shape. The bytecode VM reuses the Rust runtime rather than growing its own.