Dynamic Evaluator VM
March 9, 2026 ยท View on GitHub
Carp's dynamic evaluator is VM-backed.
This document is both a high-level architecture overview and a maintainer guide for future VM work.
Scope
The dynamic evaluator is the compile-time execution engine used for:
- dynamic code (
defndynamic, commands, primitives), - macro expansion and macro execution,
- evaluator-time forms such as
let,if,while,set!, and function calls.
src/Eval.hs is the public API surface (evalDynamic, evalStatic, eval) and delegates execution to the VM path.
Pipeline
The evaluator pipeline is:
XObj->EvalIRlowering (lowerExprinsrc/EvalIR.hs).EvalIR->EvalCodebytecode compilation (compileEvalIRinsrc/EvalVM.hs).- Bytecode execution (
runEvalCodeinsrc/EvalVM.hs).
Callables may be:
- eagerly compiled (
VMPrecompiled), - compile-on-first-call (
VMCompileOnCall) with cached compiled code.
Module map
src/Eval.hs: public evaluator entry points and integration with expansion.src/EvalIR.hs: evaluator IR, lowering, and raising (raiseExpr).src/EvalCode.hs: bytecode instruction and resolver-handle definitions.src/EvalVM.hs: compile pipeline, lookup logic, dispatch, opcode loop, caches.src/EvalVMCore.hs: low-level frame/code-store execution for registered callable code.src/EvalSlotLowering.hs: function-local slot lowering for fast local variable access.src/EvalBound.hs,src/EvalBind.hs: bound reference representation/helpers.src/EvalTypes.hs: evaluator lookup and execution-mode types.
Execution modes and lookup preference
Evaluator behavior is mode-driven via LookupPreference:
PreferDynamicPreferGlobalPreferLocal ... ExecFunction|ExecDynamic|ExecMacro
Mode controls lookup and callable compilation policy:
- Function mode expects local slots to exist and fails fast when missing.
- Dynamic and macro modes keep dynamic-friendly lookup behavior.
Bytecode model
EvalCode is a list plus array form of instructions. Important instruction classes:
- stack/value ops:
IPushConst,IMakeArray,IMakeStaticArray,IDrop, - control flow:
IJumpIfFalseRel,IJumpRel,IHalt,ITrap, - symbol/call dispatch:
IResolveSymbol,IExecCallSymbol,IExecCall, - special-form execution ops:
IExecLet,IExecFn,IExecWhile,IExecWith,IExecSet.
IExecCallSymbol and IExecCall carry both raw argument IR and precompiled argument code, so non-macro argument evaluation avoids recursive IR re-entry in hot paths.
Callable representation and transparency
The VM introduces VMClosure for executable callable payloads, but language-level semantics should remain transparent.
Current contract:
- dynamic/macro definitions keep legacy outer shape
(dynamic|macro name params body), - executable body may be a VM-backed closure internally,
- user-visible macro and dynamic behavior should match pre-VM semantics unless explicitly changed.
Caching
There are three relevant caches:
- IR-to-bytecode cache keyed by positive
Info.infoIdentifier(evalIRCacheKey), - symbol resolution cache in opcode loop keyed by
(contextBindingEpoch, symbolId), - callable resolution cache (same keying strategy) for call dispatch.
contextBindingEpoch is used to invalidate cached bindings when environment state changes.
Symbol resolution model
Compilation assigns each symbol a ResolverHandle:
RHLocalSlotRHGlobalRHDynamicRHQualifiedRHUnqualified
The opcode loop resolves by handle shape first and only falls back to broader lookup where required by semantics.
Slot lowering invariants
Function mode relies on slot lowering:
- function parameters and local references are lowered to slot refs,
- unresolved local refs after slot lowering are treated as an error,
set!updates sync back into local slot state to keep reads coherent after mutation.
If you change binding/lowering behavior, keep these invariants intact or update tests and docs together.
How to make VM changes safely
When changing evaluator behavior:
- decide if this is implementation-only or intended language semantics,
- if implementation-only, preserve outer forms and error surface,
- update/extend evaluator tests (
test/TestEvalIR.hs,test/TestEvalSlotLowering.hs,test/TestEvalVM.hs,test/TestEvalVMCore.hs), - run macro-heavy and dynamic-closure tests (
test/macros.carp,test/dynamic-closures.carp), - benchmark (
./bench/run-evaluator-bench.sh) and compare medians, not single runs.
Performance workflow
Primary benchmark script:
./bench/run-evaluator-bench.sh
Use medians from repeated runs and compare at least:
- baseline startup,
- evaluator benchmark (
bench/evaluator.carp), - real-world macro workload (
test/macros.carp).
Current non-goal
The evaluator VM is internal compiler infrastructure. It is not a user runtime VM and does not change Carp's compiled-code execution model.