1. Dispatch by bytecode index
Before, every instruction looked up its handler in a dictionary and checked the lookup result inside the execution loop:
IOpCode handler;
if (!ByteCodeOpCodes.TryGetValue(opcode, out handler))
throw new InvalidProgramException(...);
handler.Execute(state);
Now the byte opcode indexes a 256-slot array directly:
ByteCodeOpCodes[instruction.OpCode].Execute(state);
Compile checks that a handler exists while it reads the bytecode. The execution loop no longer hashes a key or repeats the unknown-opcode branch for every instruction. The selected IOpCode handler still performs the instruction's stack work.
2. Keep numeric values in the VM stack
Before, Stack<object> held each numeric value as a boxed object:
private readonly Stack<object> _stack = new Stack<object>();
object right = state.Pop();
object left = state.Pop();
state.Push(NumericArithmetic.Apply(operation, left, right));
Now the VM owns a resizable array of tagged values and a stack count:
private VirtualMachineValue[] _stack = new VirtualMachineValue[4];
private int _stackCount;
VirtualMachineValue right = state.Pop();
VirtualMachineValue left = state.Pop();
state.Push(NumericArithmetic.Apply(operation, left, right));
VirtualMachineValue stores the kind and numeric bits inline; its reference field carries strings and other objects. This removes boxing of intermediate numeric values during arithmetic. The old Stack<object> was itself array-backed, so the change is not from a linked list to contiguous memory: it is an explicit value array with direct stack indexing and inline numeric storage.
Boxing remains at the current method boundary. Rewritten stubs place value-type arguments in an object[], and Execute returns an object, which can box a value-type result. Each call also creates its VM state and stack buffer.
3. Decode each method once
Before, the execution call received raw bytes and read opcodes and operands again each time:
Execute(byte[] byteCode, object[] arguments)
// read opcode and operand, then run handler
// repeat for every instruction on every call
Now the bytecode is compiled to an instruction array and passed to the same shared interpreter:
VirtualMachineProgram program = Compile(byteCode); // generated type initializer
Execute(program, arguments); // each method call
The generated cache type's static initializer calls Compile for its converted methods and stores each resulting program in a static field. The first access initializes that cache type; later calls reuse the decoded instruction arrays. Immediate values and ldstr strings are decoded before the execution loop.
This moves work to first use and keeps each compiled program in memory for the lifetime of its cache type. The execution call still creates a state and the rewritten method still builds its argument array. All protected methods continue through the one Execute(VirtualMachineProgram, object[]) path and their IOpCode handlers; this does not replace interpretation with generated native code.
A narrow before-and-after measurement
I built both revisions in Release mode with SDK 10.0.401 and ran them on Windows 11 x64 with .NET 9.0.18 and an Intel Core i9-14900K. The before build uses repository revision 69fed23. The test bytecode starts at zero and adds one 64 times, for 129 instructions and 64 Add operations per VM call. Each timed round made 50,000 calls with the same empty argument array. The old path decoded the raw bytecode on every call; the new path compiled it once before measurement. Seven timed rounds followed 2,000 warm-up calls; the first timed round was excluded and the median of the remaining six is shown.
| Measurement | Before | After | Difference |
|---|---|---|---|
| Median time (range) for 50,000 calls | 83.9 ms (82.4–84.7) | 49.9 ms (49.3–51.0) | 1.68× in this workload; 40.5% less elapsed time |
| Managed allocation per call | 3,232 bytes | 208 bytes | 93.6% fewer allocated bytes |
| Returned checksum per round | 3,200,000 | 3,200,000 | Same result |
Allocation counts came from GC.GetAllocatedBytesForCurrentThread; bytecode construction, the one-time compile, and the shared empty argument array were outside the timed region. The benchmark compares all three runtime changes together, so it does not attribute the difference to dispatch, the stack, or decoding separately.
For this constant-heavy I4 program on this .NET 9 x64 machine, the revised interpreter completed the measured calls sooner and allocated fewer managed bytes. This is one microbenchmark, not a general speed guarantee. It excludes generated method-stub work, non-empty argument arrays, other value kinds, other runtimes, and first-use compilation cost.
The implementation was also covered by 25 unit tests and the 26-entry original-versus-protected sample matrix in x86 and x64 processes. Those checks verify the tested behavior across the configured targets; they are not performance measurements.