Interpreter performance lives and dies by L1 cache locality and instruction dispatch cost. When building a bytecode interpreter or dynamic language runtime in Rust—similar to challenges faced when building a functioning Python interpreter in 1024 bytes—the idiomatic choice for representing dynamically typed values is a tagged enum. A standard enum Value { Nil, Bool(bool), Int(i64), Float(f64), String(Arc<String>) } provides complete type safety and pattern matching aligned with Rust ownership patterns.
That safety comes with a hidden tax on CPU caches and execution branches. Replacing a standard 16-byte tagged Rust enum with a packed 64-bit integer word dropped overall bytecode evaluation time by 17 percent in a production micro-vm project. Speedup comes directly from shrinking data structures to fit more items into cache lines and eliminating redundant memory loads during type checking.
Here is analysis of why tagged enums hurt interpreter inner loops, how bit-packed 64-bit value representations work in Rust, and exact performance trade-offs involved.
The Secret Cost of Rust Tagged Enums
Rust memory layout rules prioritize field alignment and fast field access over minimal memory footprint. Standard Rust enum consists of two parts: discriminant (tag tracking active variant) and payload (largest fields required by any single variant).
Consider typical value type for dynamic scripting engine:
pub enum Value {
Nil,
Bool(bool),
Int(i64),
Float(f64),
Object(*const HeapObject),
}Discriminant needs 1 byte to distinguish 5 variants. Standard 64-bit pointer alignment rules dictate i64, f64, and *const HeapObject align to 8-byte boundaries.
Rust places 1-byte discriminant at start of struct, adds 7 bytes of padding, puts 8-byte payload right after. Resulting layout:
Byte offset: 00 01 02 03 04 05 06 07 | 08 09 10 11 12 13 14 15
Content: [Tag] [ Padding ] | [ Payload ]Every single Value instance takes 16 bytes memory, even for simple boolean or null marker.
When evaluation stack or register file holds thousands of values, memory footprint adds up fast:
- Poor L1 Data Cache Density: Standard CPU L1 data cache line is 64 bytes long. With 16-byte enums, single cache line holds only 4 value slots. If stack holds 64-bit packed words instead, single cache line holds 8 value slots.
- Two-Stage Memory Access: To read integer out of enum, CPU reads tag byte from offset 0, checks if match for
Int, then fetches actual 8-byte payload from offset 8. - Array Vector Misalignment: Vectorized SIMD operations on primitive values become impractical because data elements are separated by tag header bytes and alignment gaps.
In inner dispatch loop of interpreter, execution spends significant time shuffling values back and forth between memory and registers. Doubling cache footprint means twice as many cache misses during tight loops.
Understanding 64-Bit Word Representation
Instead of letting compiler manage memory layouts through enums, represent every value as raw 64-bit unsigned integer (u64).
Modern x86_64 and AArch64 processors use 64-bit address space, but operating systems only use lower 48 bits for virtual address mapping. Top 16 bits of valid user-space pointer are always zeros.
Double-precision floating-point numbers (f64) follow IEEE 754 standard. IEEE 754 float uses 1 bit for sign, 11 bits for exponent, 52 bits for mantissa. When all 11 exponent bits are 1, value represents NaN (Not a Number). Remaining 52 mantissa bits store arbitrary payload data without breaking standard CPU float hardware.
Two common packing strategies: NaN-boxing and Pointer Tagging.
Strategy 1: NaN-Boxing
NaN-boxing stores double-precision floats as raw bit patterns. Bit pattern not matching Quiet NaN is interpreted directly as f64.
If bit pattern matches specific NaN pattern, upper bits mark value as special type (Integer, Boolean, Pointer, Null), lower 48 bits contain actual value or pointer address:
Float: [s exp (11 bits) ] [ mantissa (52 bits) ]
NaN Tag: [ 1111111111111 ] [Tag (3b)] [ 48-bit Payload ]Strategy 2: Explicit Low-Bit / High-Bit Tagging
If floating-point performance matters less than integer and pointer access, split 64-bit word using explicit masks. Heap objects align to 8-byte boundaries, so lower 3 bits of valid heap pointer are guaranteed zero (000).
Map tag into lowest bits:
xxx...x000: Pointer to Heap Object (lower 3 bits000)xxx...x001: 63-bit Signed Small Integer (shifted left by 1)xxx...x010: Boolean / Special Constant- IEEE 754 Float stored inside dedicated heap cell or using high-bit tags
Low-bit tagging selected due to lower bit-shift overhead during integer arithmetic.
Implementing Packed Words in Rust
To maintain type safety outside hot loop, wrap raw u64 inside tuple struct decorated with #[repr(transparent)]. Guarantees zero memory overhead while defining clean helper methods.
#[derive(Copy, Clone, PartialEq, Eq)]
#[repr(transparent)]
pub struct Value(u64);
const TAG_MASK: u64 = 0b111;
const TAG_PTR: u64 = 0b000;
const TAG_INT: u64 = 0b001;
const TAG_BOOL: u64 = 0b010;
const TAG_NIL: u64 = 0b011;
const VAL_NIL: Value = Value(TAG_NIL);
const VAL_TRUE: Value = Value((1 << 3) | TAG_BOOL);
const VAL_FALSE: Value = Value((0 << 3) | TAG_BOOL);
impl Value {
#[inline(always)]
pub fn from_int(val: i64) -> Self {
// Shift signed integer by 3 bits to make room for low tag
Value(((val as u64) << 3) | TAG_INT)
}
#[inline(always)]
pub fn is_int(self) -> bool {
(self.0 & TAG_MASK) == TAG_INT
}
#[inline(always)]
pub fn as_int(self) -> i64 {
// Arithmetic right shift restores sign bit
(self.0 as i64) >> 3
}
#[inline(always)]
pub fn from_ptr<T>(ptr: *const T) -> Self {
let addr = ptr as u64;
debug_assert_eq!(addr & TAG_MASK, 0, "Pointer must be 8-byte aligned");
Value(addr | TAG_PTR)
}
#[inline(always)]
pub fn is_ptr(self) -> bool {
(self.0 & TAG_MASK) == TAG_PTR && self.0 != 0
}
#[inline(always)]
pub fn as_ptr<T>(self) -> *const T {
(self.0 & !TAG_MASK) as *const T
}
}Notice use of #[inline(always)]. Because operations reduce to single bitwise instructions (AND, SHL, SAR), inlining allows Rust compiler to collapse type checks directly into surrounding arithmetic operations.
Assembly Output Comparison
Refactoring impact visible when comparing generated assembly for basic operations like integer addition on stack values.
Original Tagged Enum Code
pub fn add_values_enum(a: ValueEnum, b: ValueEnum) -> ValueEnum {
match (a, b) {
(ValueEnum::Int(x), ValueEnum::Int(y)) => ValueEnum::Int(x + y),
_ => panic!("Type error"),
}
}Generated x86_64 assembly (simplified target output):
add_values_enum:
mov al, byte ptr [rdi] ; Load tag of 'a'
cmp al, 2 ; Check if Tag == Int
jne .Lerror
mov cl, byte ptr [rsi] ; Load tag of 'b'
cmp cl, 2 ; Check if Tag == Int
jne .Lerror
mov rax, qword ptr [rdi + 8] ; Load 64-bit payload 'a'
add rax, qword ptr [rsi + 8] ; Load 64-bit payload 'b' and add
mov byte ptr [rdx], 2 ; Write output tag
mov qword ptr [rdx + 8], rax ; Write output payload
retCompiler generates two byte loads for tags, two comparison checks, two conditional branches, two 8-byte payload loads from distinct memory offsets.
Packed 64-Bit Word Code
pub fn add_values_word(a: Value, b: Value) -> Value {
if a.is_int() && b.is_int() {
// Fast path: bitwise check both tags simultaneously
let combined_tags = (a.0 & TAG_MASK) | (b.0 & TAG_MASK);
if combined_tags == TAG_INT {
// Strip tag from one operand, add directly
return Value(a.0 + (b.0 & !TAG_MASK));
}
}
panic!("Type error");
}Generated x86_64 assembly:
add_values_word:
mov rax, rdi
or rax, rsi
and eax, 7
cmp eax, 1 ; Verify both operands carry TAG_INT
jne .Lerror
mov rax, rsi
and rax, -8 ; Mask out low tag bits
add rax, rdi ; Direct 64-bit register addition
retPacked word version loads inputs into general-purpose registers (rdi, rsi), combines tag validation into single bitwise OR and AND operation, performs arithmetic without touching memory again, applying core concepts from branchless Rust optimization to maintain linear pipeline execution.
Benchmark Results
Testing conducted on custom bytecode interpreter executing recursive Fibonacci calculations, array sorting loops, heavy object allocation benchmarks.
Test machine: AMD Ryzen 9 5900X, Linux 6.5, Rust 1.78 release build with opt-level = 3 and lto = "fat".
| Metric | Tagged Enum (16 bytes) | Packed Word (8 bytes) | Improvement |
|---|---|---|---|
| Fibonacci (35) Time | 1.82 seconds | 1.51 seconds | 17.0% faster |
| Array Sort (100k items) | 42.1 milliseconds | 34.2 milliseconds | 18.7% faster |
| L1 Data Cache Misses | 14.2M misses | 9.1M misses | 35.9% reduction |
| Instructions per Cycle (IPC) | 1.94 | 2.38 | 22.6% increase |
Drop of 35.9 percent in L1 cache misses confirms main hypothesis: fitting twice as many evaluation items into same cache lines reduces stalls while reading opcode arguments.
Because register pressure decreased (8 bytes per item instead of 2 distinct fields), CPU pipeline executes more instructions per cycle.
Engineering Trade-Offs
Switching away from idiomatic Rust enums comes with downsides to manage carefully.
Loss of Compiler Type Checking
Rust pattern matching guarantees exhaustiveness. Adding new variant to enum flags missing match arm across code base.
With raw u64 words, compiler sees integer. Forgetting tag check corrupts data or dereferences invalid pointers without compile error.
Mitigation:
- Keep packed word usage isolated inside virtual machine execution core (
vm/exec.rs). - Wrap low-level bit operations inside well-tested safe wrapper modules.
- Add debug assertions (
debug_assert!) to conversion functions.
Pointer Alignment Requirements
Low-bit tagging requires heap-allocated objects have alignment of at least 8 bytes. If memory allocator returns 4-byte aligned pointers, lowest bits collide with tag masks, causing memory corruption.
Force custom alignment on heap types using #[repr(align(8))]:
#[repr(C, align(8))]
pub struct HeapObject {
pub header: ObjectHeader,
pub payload: ObjectData,
}32-Bit Platform Limits
On 32-bit architectures, pointers are 32 bits wide. Storing 32-bit pointers inside 64-bit word works, but integer bit-shifting strategies require adjustment on small embedded microcontrollers.
Implementation Guidelines
Do not apply everywhere. Standard Rust enums are correct choice for most components.
Use 64-bit packed words when:
- Writing hot execution loops (interpreters, virtual machines, database query engines).
- Allocating millions of small tagged values.
- Profiling tools (
perf,valgrind) show high L1 data cache miss rates on enum dispatch loops.
Stick to standard Rust enum when:
- Designing public library APIs where readability and API safety matter most.
- Variant payloads carry complex non-pointer data types.
- Execution speed limited by I/O, network latency, or database queries.



